Compare commits

..
Author SHA1 Message Date
james.maes b79487dad7 Merge remote-tracking branch 'origin/main' into fix/tui-assistant-render 2026-11-01 13:16:18 -06:00
james.maesandClaude Fable 5.1 f180a12750 fix(cli): broken-pipe exits are quiet on every path; deterministic EPIPE tests (#16)
isBrokenPipe also covers ECONNRESET, ERR_STREAM_DESTROYED and ERR_STREAM_WRITE_AFTER_END; an uncaughtException handler exits 0 on a broken pipe; io.out/io.err go through safeWrite; the top-level catch exits 0 immediately. The tests close the read end before the first write and after the first chunk of a 5,000-record answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:13:24 -06:00
james.maesandClaude Fable 5.1 fc7a90ce40 feat(tui): rendered assistant answers, F12 shows and hides the panel
The panel drew answers as plain text (owner: "the assistant needs some
work"). It now renders them through the kernel's markdown parser:
entities decoded, headings in the accent colour, wrapped paragraphs,
lists, bold and code, pipe tables laid out like the grid and clipped;
links as `label ↗` only, numbered Tab targets in reading order, once per
target, `u` shows a link's address, and the → list keeps only links the
text does not show. Duplicate paragraphs collapse; the timing line stays.

F12 shows and hides the panel on every screen (owner: "a function key"):
hiding keeps the conversation, context and draft; Esc hides without
asking; Ctrl+G, :ask and a click on the new 12Ask slot (from 120 columns,
dimmed without the extension; 11 stays blank and reserved) do the same.
OpenTUI reads F12 as both the legacy ESC [24~ and the kitty protocol key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:10:39 -06:00
james.maesandClaude Fable 5.1 9f8d652028 fix(assistant): render the answer's markdown in qqq assistant ask
The CLI printed the model's markdown as it came: HTML entities
(`healthy&#58;`), pipe tables with `| --- |` rows and URL-encoded
/app/...?filter= link targets, then the same links again. The kernel
gets a small markdown parser (render/markdown.ts: entities decoded,
headings, paragraphs, lists, bold, code, pipe tables, links numbered once
per target; a filter on the primary key is that record, JSON and base64
filters compare by content; exact duplicate paragraphs collapse), and
`assistant ask` prints whole blocks as they complete: tables as
sub-tables, links as `label (qqq table get ...)` or `label [n]` with the
command under Links, never a URL; tool-result links only when the text
does not show the same target. Tested with the 8078 answer about
unhealthy services as a fixture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:10:39 -06:00
james.maesandClaude Fable 5.1 924fddae25 packaging: qqq 1.0.0 checksums; update-packaging refuses missing or zero sums (#15)
docs/packaging carries the real sha256 sums from the cli-v1.0.0 release. scripts/update-packaging.mjs now parses shasum lines strictly, requires 64 lowercase hex and non-zero sums for every target, validates both files before writing anything, and exits 1 with nothing written otherwise; covered by scripts/update-packaging.test.mjs (npm test runs test:scripts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:03:32 -06:00
james.maesandClaude Fable 5.1 1bc4ddd0be release: qqq 1.0.0 (#11)
qqq 1.0.0: the command-line client and the full-screen terminal UI (qqq ui) for QQQ applications over the UI API (/qqq/v1), with @qrun-io/qqq-cli 1.0.0, @qrun-io/qqq-core 1.0.0, @qrun-io/qqq-ui-api-client 1.8.0 and @qrun-io/qqq-ui-api-conformance 1.6.0 going final together. Squash of the release/1.0.0 branch at 79ba68e (the branch is kept with the full signed history; the tags v1.8.0 and cli-v1.0.0 point at that commit, which is what the released binaries were built from). See CHANGELOG.md [1.0.0] and docs/release-notes/1.0.0.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:49:06 -06:00
james.maesandCodex 603c09035b ci: adopt managed orb 0.8 patch track (#8)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:55:30 -05:00
james.maesandCodex 9294d3ee6b ci: adopt shared orb 0.7.17 (#7)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:17:41 -05:00
james.maesandCodex ff248b62c2 ci: adopt central orb 0.7.12 and stable gates (#6)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:49:28 -05:00
james.maesandCodex 2f193c088b ci: adopt shared orb 0.7.11 (#5)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:53:13 -05:00
james.maesandCodex 18723ae8c2 ci: centralize CLI verification and package publishing (#4)
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 19:50:29 -05:00
james.maes 9816569a3d Merge pull request #1 from QRun-IO/feature/initial-cli
feat: reference CLI, generated UI API client, mock and conformance suite
2026-10-15 13:46:04 -05:00
503 changed files with 101419 additions and 21229 deletions

No files matched your search

+11 -13
View File
@@ -1,16 +1,14 @@
version: 2.1
# Build and test on CircleCI (as the other QRun-IO repos); GitHub Actions also
# runs .github/workflows/ci.yml and publishes on version tags.
jobs:
build:
docker:
- image: cimg/node:22.12
steps:
- checkout
- run: npm ci
- run: npm run build
- run: npm test
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
build:
setup:
jobs:
- build
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /.*/
+2
View File
@@ -0,0 +1,2 @@
# Every change needs a review from the maintainer.
* @KofTwentyTwo
+33
View File
@@ -0,0 +1,33 @@
---
name: Bug report
about: Something in qqq, qqq ui or one of the packages does not work as documented
title: "[BUG] "
labels: bug
assignees: ''
---
## What happened
<!-- What you ran and what you saw. Paste the output (remove tokens, passwords and private hostnames). -->
```console
$ qqq ...
```
## What you expected
## Environment
- First line of `qqq doctor` (version, binary or npm, runtime, platform):
- Installed with: <!-- curl installer, Homebrew, Scoop, direct download, npm -->
- Server UI API version (`qqq whoami`) and authentication type:
- Terminal and multiplexer, for `qqq ui` problems (e.g. iTerm2 + tmux 3.5, Windows Terminal):
## Steps to reproduce
1.
2.
## More
<!-- `--trace` output for unexpected errors, the X-QQQ-Trace-Id or traceId from an error, screenshots of qqq ui. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/QRun-IO/qqq-cli/blob/main/SECURITY.md
about: Do not open a public issue; report it privately as SECURITY.md describes.
- name: QQQ server or UI API problem
url: https://github.com/QRun-IO/qqq/issues
about: Problems in the QQQ server or the UI API itself belong in the qqq repository.
@@ -0,0 +1,17 @@
---
name: Documentation issue
about: Something in a README, TRY-IT.md or docs/ is wrong, missing or unclear
title: "[DOCS] "
labels: documentation
assignees: ''
---
## Where
<!-- File and section, e.g. packages/tui/README.md, "Keys". -->
## What is wrong or missing
<!-- For a command that does not behave as documented, include the command and its output. -->
## Suggested fix
+26
View File
@@ -0,0 +1,26 @@
---
name: Feature request
about: A command, screen or package API that is missing
title: "[FEATURE] "
labels: enhancement
assignees: ''
---
## The problem
<!-- What you are trying to do and why the CLI, the TUI or the packages do not let you today. -->
## What you would like
<!-- The command line, keys or API you have in mind. -->
## Where it applies
- [ ] CLI (`qqq`)
- [ ] Terminal UI (`qqq ui`)
- [ ] `@qrun-io/qqq-core` or `@qrun-io/qqq-ui-api-client`
- [ ] Conformance suite
## Web UI parity
<!-- If the QQQ web UI already does this, say where; docs/parity/feature-matrix.md tracks parity. Does it need a UI API change in QQQ? -->
+50
View File
@@ -0,0 +1,50 @@
# Dependabot version updates for qqq-cli. Security updates are enabled separately in the repository
# settings and are not limited by these groups.
version: 2
updates:
# The npm workspaces (ui-api-client, core, conformance, cli) share the root package-lock.json.
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "npm"
commit-message:
prefix: "deps"
include: "scope"
groups:
npm-minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# The terminal UI (packages/tui) is not an npm workspace: it is installed with Bun from its own
# text lockfile (bun.lock), which Dependabot's "bun" ecosystem reads. OpenTUI pins are exact on
# purpose (the native library must match); review those bumps with npm run test:tui and the tmux smoke.
# packages/tui-spike is a throwaway spike and is deliberately not listed.
- package-ecosystem: "bun"
directory: "/packages/tui"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "tui"
commit-message:
prefix: "deps"
include: "scope"
groups:
tui-minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
+34
View File
@@ -0,0 +1,34 @@
## What and why
<!-- What this pull request changes and the reason for it. Link the issue: Closes #123 -->
## Type of change
- [ ] Bug fix
- [ ] New feature or command
- [ ] Breaking change (CLI grammar, output shape, exit code, or a package's exported API)
- [ ] Documentation
- [ ] Refactoring, tests or build
## Packages touched
- [ ] `@qrun-io/qqq-cli`
- [ ] `@qrun-io/qqq-core`
- [ ] `@qrun-io/qqq-ui-api-client` (regenerated with `npm run generate`?)
- [ ] `@qrun-io/qqq-ui-api-conformance`
- [ ] `packages/tui` (`qqq ui`)
## Checks
- [ ] `npm ci && npm run build && npm test && npm run lint` pass
- [ ] `npm run test:tui` passes (when `packages/tui` or the kernel changed)
- [ ] Tried against a server (the QQQ sample server, or say which), with the commands used below
- [ ] `CHANGELOG.md` has an entry under the next version for user-visible changes
- [ ] README, `TRY-IT.md` or the package README updated for new or changed commands
- [ ] Commits are signed and use conventional-commit subjects
## How it was tested
```sh
# commands run, and against which server
```
-28
View File
@@ -1,28 +0,0 @@
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Generated client is up to date with the vendored document
run: |
npm run generate
git diff --exit-code packages/ui-api-client/src/schema.ts
- run: npm run build
- run: npm test
- name: Mock server smoke test (Prism on the merged document)
run: |
npx prism mock --port 4010 packages/ui-api-client/openapi/qqq-v1.json > prism.log 2>&1 &
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:4010/qqq/v1/version && break; sleep 1; done
curl -fsS http://127.0.0.1:4010/qqq/v1/metaData/authentication
-33
View File
@@ -1,33 +0,0 @@
name: publish
# Publishes the three packages to GitHub Packages (npm.pkg.github.com, scope @qrun-io)
# when a version tag (v*) is pushed. The tag must match the client package version.
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: '@qrun-io'
- run: npm ci
- run: npm run build
- run: npm test
- name: Tag matches the client version
run: test "v$(node -p "require('./packages/ui-api-client/package.json').version")" = "${GITHUB_REF_NAME}"
- run: npm publish -w @qrun-io/qqq-ui-api-client --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm publish -w @qrun-io/qqq-ui-api-conformance --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm publish -w @qrun-io/qqq-cli --tag rc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -3,3 +3,5 @@ dist/
*.tgz
.env
.spec-src/
packages/*/LICENSE
log/
+7
View File
@@ -0,0 +1,7 @@
profile: cli
orb_version: '0.8'
default_branch: main
publish:
release: true
publish_context: qqq-maven-registry-credentials
platforms: [macos-arm64, windows-x64]
+483
View File
@@ -0,0 +1,483 @@
# Changelog
All notable changes to the qqq CLI and its packages. Versions follow [semver](https://semver.org);
`@qrun-io/qqq-ui-api-client` follows the UI API version of the document it is generated from.
## [Unreleased]
### Added
- **F12 shows and hides the Assistant panel** on every screen (Ctrl+G, `:ask` and a click on `12Ask` do
the same). Hiding keeps the conversation, its context and the draft; Esc hides too (with a draft,
nothing is asked). The F-key bar has `11` (reserved, blank) and `12Ask` from 120 columns (dimmed
without the extension); narrower, the `?` help and F9 › Work say "F12 assistant". F12 is read as the
legacy `ESC [24~` and as the kitty keyboard protocol key; README notes macOS's F12 mapping.
- Assistant panel: `u` on a link shows its address.
- Kernel: `parseMarkdown`, `markdownText`, `answerDocument` and `linkTargetKey` (render/markdown.ts): the
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
that record; JSON and base64 filters compare by content).
### Fixed
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
`qqq assistant ask`: HTML entities decoded (`healthy&#58;` was shown as is), headings, wrapped
paragraphs, lists, bold and code, and pipe tables laid out as a grid (CLI: sub-tables) instead of `| --- |`
rows. Links show as `label ↗` (CLI: `label (qqq table get …)` or `label [n]` with the command under
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
when the text does not already show the same target, so `db_performance` no longer appears three times.
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
## [1.0.0] - 2026-11-01
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
binary per platform. Packages: `@qrun-io/qqq-cli` 1.0.0, `@qrun-io/qqq-core` 1.0.0 (new),
`@qrun-io/qqq-ui-api-client` 1.8.0, `@qrun-io/qqq-ui-api-conformance` 1.6.0. Servers: UI API 1.6.0 up
to 2.0 (QQQ 4.1.0-RC.15 or later). Released as 1.0.0-rc.1 first (tags `v1.8.0-rc.2`,
`cli-v1.0.0-rc.1`); this entry is that candidate's plus everything that landed after it. Short commit ids
in parentheses mark the changes made after the candidate.
### Added
#### Command line
- **The grammar** is `qqq <noun> <verb>` (`table query`, `process run`, ...); the 0.1 flat commands
(`apps`, `tables`, `query`, `get`, `run`, `jobs`, `tail`) still work as aliases.
- **Sign-in**: session sign-in for MOCK and anonymous servers; OAuth2 device flow and browser sign-in with
PKCE on a loopback redirect; TABLE_BASED `qqq login --username` (password asked, or `QQQ_PASSWORD`; only
the server session is kept, a refused password stores nothing); refresh before expiry; `logout
--provider-logout` revokes (RFC 7009) and opens the end-session page; refused refreshes exit 3. Sign-ins
live in the macOS Keychain, Linux Secret Service, Windows DPAPI or a 0600 file.
- **Profiles** (`--profile`, `QQQ_PROFILE`) with a **short name** (2 to 6 characters, for the footer and
Home), a **description**, a **sign-in mode** for OAuth2 servers (`auto` asks, `browser`, `device`) and
the last use and user; all additive in `config.json`, so files written by 1.0.0-rc.1 load unchanged and
keep their `note` (shown when there is no description). `qqq profile list|add|edit|use|remove` (also
under `qqq config profile`): `add` and `edit` take `--short`, `--description`, `--auth-mode` and
`--check` (refuse unless the server answers), `edit` takes `--server` and `--rename`, an empty value
removes a field, `list --probe` adds each server's status (d9317f5).
- **Tables**: `list`, `describe`, `query` (filters, sort, paging, `--search`, `--join` and `table.field`
columns, `--count`, `--print-filter`, saved views with `--view`), `count`, `stats` (server columnStats, else
computed from the matching records with a note on stderr; `--local` forces it, `--no-local` restores the
error, `--max-records` caps the read at 10,000 by default; 7c66b16), `get` (sections, adornments, `--associations`, `--links`, `--follow`, `--key`, `--copy`),
`create`, `copy`, `update` (`-e` edits HTML and code fields in `$EDITOR`), `delete`, `children`,
`export` (csv, tsv, xlsx, json), `download` and `--upload` for file fields, `variants` and `--variant`
everywhere (the chosen variant is remembered per server), `columns`, `actions` (what you can do to a
table, with the command for each), `api-call` and `widget-form`.
- **Forms**: contingent fields (country, state, city) with narrowing possible values, labels or unique
partial labels for ids (also in `--where`), local validation with the server's codes, form adjusters,
interactive `-i`, CHIP values in their colours.
- **Filters**: the grammar (shorthand operators, quoting, AND/OR groups, expression values such as
`now(-7d)`, `thisPeriod(month)`, `me()`). Text filters ask for case-insensitive matching (`CONTAINS`,
`STARTS_WITH`, `ENDS_WITH`, quick search); `~=`, `OPERATOR:CS` or `--case-sensitive` ask for a
case-sensitive match. The server does not read the option over the UI API yet (finding 27), so matching
stays case-sensitive until it does.
- **Processes**: `list`, `describe`, `run` with every step component (help, HTML as text, view pairs,
process records, the validation review, summary lines, downloads, widgets), asynchronous jobs with
progress, `--file` for BLOB fields, back steps, min/max input records, and Ctrl-C that cancels the run on
the server (exit 130).
- **Bulk load and bulk changes**: `table bulk-insert` from a CSV or Excel file (the upload template, the
server's column suggestion, a mapping document, flags or `-i`, value mapping, the validation review,
`--dry-run`), `table bulk-edit` of chosen records or by key rows from a file, `table bulk-delete` with
review and confirmation, and `bulk-profile list | save | rename | delete` for saved mappings.
- **Dates**: DATE_TIME typed and shown in the local zone (`TZ`), sent as UTC instants; `--utc` for raw.
- **More nouns**: `apps show` (app homes with counts, `--widgets`), `home` (counts, quick actions, recent
records), `find` (the command palette: an app, table, process or report by label), `tail`, `saved-view`,
`audit list`, `report run` and `saved-report list | show | preview | run | set | pivot | schedules |
schedule`, `share` (records of shareable tables), `widget` (charts and tables as text, `--record`,
`--run`), `job`, `prefs`, `help show`, `help-search`, and `api <operationId>` for any of the 219
operations.
- **Search**: `qqq search` shows the matched field and snippet (`match`), takes `--table` repeated or as a
comma list, `--limit` per table, names the tables and the total in the footer, searches `field:value`
with a note, lists the quick search syntax in `--help` and prints the whole response with `--json`
(0e07f8a).
- **Developer mode and scripts**: `table apis`, `table api-doc`, `table developer`, and `script list | show
| store | test | logs`.
- **Extensions**: a noun per extension the client document knows (14: esb, collab, health, alerts,
assistant, sql-workbench, ...), gated on the server's installed extensions and documented minimum
versions; first-class verbs for `esb` and `collab` (presence, comments, favorites, follow, my work, tags
by id or label). Results print scalars as aligned key/value rows (nested objects as dotted keys) and each
list of objects as a sub-table fitted to the terminal width, with default columns for the inbox,
notebooks, SQL history, comments, tasks, favorites and recents and `--columns a,b` (or `--fields`) to
choose; `--json`, yaml, csv and text are unchanged (5e34433).
- **Printing** (the printing extension, qbit-printing 2.x): `qqq print <table> <id...>` renders each record
as a record sheet through the table's Print process, queues one job per record and follows the jobs until
they have printed (`--printer`, `--copies`, `--no-wait`, `--timeout`; exit 1 when a job failed, 4 when
jobs are still running at the timeout); `qqq print list <table>` prints a table list (criteria, order,
columns, `--page`, paper and orientation; up to 10,000 rows); `qqq print text [file|-]` prints text as a
monospace PDF; `qqq print job list|status [--watch]|cancel`; `qqq printer list|use|show|forget|devices`.
The printer chosen is remembered per profile (`printing.printers` in config.json). Without the extension
the commands exit 5 with the installed list (c1e7bf9, 4bd62b7, d632987, d81c053).
- **Disabled tables and processes** are listed with the reason, and queries, writes, copies, bulk commands
and runs on them are refused before any request, as the web explains the denial.
- **Shell completion** for bash, zsh, fish and PowerShell from the local metadata cache; `cache show |
refresh | clear`; `config set theme` and `download-dir`.
- **Diagnostics and conventions**: `qqq doctor`, `qqq --version` (the build and the supported UI API
range), build lines in `qqq doctor` and `qqq ui --check`, consistent exit codes (2 usage, 3 auth, 4
unreachable or 502/503/504, 5 not found, 6 validation, 70 server error), downloads that never overwrite a
file or leave `--out-dir`.
- **History**: `qqq history` lists everything you did with the CLI and the TUI on the profile in use,
like a shell history (number, relative or `--absolute` time, client, kind, summary; `--limit`, `--since
2h|2026-11-01`, `--grep`, `--kind`, `--all-profiles`, `--commands`, `--format`); `qqq history rerun <n>`
runs a line again (asking first for commands that change data), `qqq history clear` empties it. The
activity log is one JSONL file per profile in `~/.local/state/qqq/activity/`, rotated once at 5 MB;
secrets (token, password and PIN flags, secret pairs and filter values, Authorization headers) are
written as `***`; `QQQ_HISTORY=off` turns it off (0bcc735).
- Uploading an attachment to a server without the Object Storage QBit says "This server cannot store
attachments: the Object Storage QBit is not installed."; any server failure naming a missing QBit gets one
such line (437ed3c).
- **Assistant** (assistant extension, qbit-assistant): `qqq assistant ask "<question>"` streams the
answer as the model writes it, then prints its numbers (counts, metrics) and the records and tables it
refers to as a table with the command that opens each; `--table <t> [--record <id>]` sends that page as
context the way the Next UI's panel does, `-c <id>` continues a conversation, `--json` prints the whole
answer. `qqq assistant config` shows whether it answers and with which model, or the reason
(`NOT_CONFIGURED`: no model provider, with what to configure). The generated operation subcommands stay
(a5e0cf4).
#### Terminal UI (`qqq ui`)
- **A classic full-screen UI** in the same binary and on the same profiles and sign-ins as the CLI:
function-key bar (F1 Help to F10 Quit, with Esc+digit for terminals that take the F-keys), PgUp/PgDn
everywhere, Esc back with the screen below keeping its place, a `:` command line that speaks the CLI
grammar (history, Ctrl+R, Tab completion listing every command with a description, then its arguments),
and a live footer (server and profile, sign-in, UI API version, ping, realtime stream, jobs, clock).
`qqq ui table query person -w ...` opens a screen directly.
- **Home**: greeting and connection, counts, recently viewed records, quick actions, favorites,
application cards (each with the app's icon as a glyph, an ASCII stand-in under the ASCII-glyphs
setting), work (jobs, alerts, my work, release notes) and the instance's home widgets. F10 offers Quit,
Reset (back to Home with fresh metadata) or Cancel. Home starts with the search line (0691e6f, ef2b554).
- **Search (F2)**, the main way into the data: a full screen with a search line, quick search results as you
type grouped by table, each hit's label, key and highlighted snippet with the matched field; ←→/Tab
cycle the tables, Enter opens the record (Esc returns to the hit), `o` opens the table's grid filtered to
the hits, Space marks hits for `a` (grid, export, print, bulk, processes), `f` narrows to one table, `h`
recent searches (per profile), `?` the quick search syntax. The status line shows matches, tables, time,
index size and the last reindex. Without quick search F2 searches names and the records of the current
table. `:search <text>` opens it. When nothing matches it explains the coverage ("Quick search indexes 17 of 189
tables (Customer, Order, … and 12 more) · 57 records · last reindex 10:38. Tables not indexed cannot be
found here; add them in Quick Search Admin › Quick Search Index, or press `o` to search Person by name"),
and the status line names the indexed tables (ef2b554, d76fb03, 81a044f).
- **`/` finds anything**, one search over apps, tables, processes, widgets, reports, records (quick
search, debounced as you type), commands and help, grouped under headers with the best match first
(`.`, Ctrl+K and `:find <text>` too). **F1 and `?`** are only the keyboard reference of the screen: keys
grouped by what they do, the screen's help text and a pointer to `/`, F9 and `:` (70c064e). A metadata search index keeps both fast on large instances.
- **F9, the command center, in two levels**: first the areas (Context, Go to, Work, Connection, Settings,
Help) with a count and a one-line summary each, then one area's entries with their keys. ↑↓ moves, Enter
or → opens an area, its first letter jumps to it, ← or Esc goes back, Tab steps to the next area; two
characters search every entry, listed by area; it fits 80x24 (13c020f).
- **Servers dialog** (Ctrl+O, `:servers`, F9 › Connection › Servers): every saved profile with its short
name, server, status (UI API version, probed in the background with a 2.5 s timeout), last use, who is
signed in, the sign-in and its description. Enter switches (the stored sign-in is reused, else the
sign-in runs) and Home opens on that server with fresh metadata; `n` adds, `e` edits, `d` removes, `*`
makes the default, `r` probes again, typing filters. Adding or editing checks that the server answers
`/qqq/v1/version` (3c45c8e).
- **Tables**: a server-paged grid with columns sized to the screen, fixed rows that never vanish when a
screen overflows, horizontal scroll, sort, view settings, quick search, column statistics, saved views,
table variants, selection with a bulk bar, export and go-to by key. Large tables stay quick (a 10,000 x
40 table draws its first page in about 100 ms).
- **The filter dialog (F6)**: every condition is a row of field, operator and value, edited the way its
type asks (text and numbers in place, choices from a searchable list, Space cycles true/false, dates
with presets or typed, operators that fit the field); a row without a value shows `(any)` and is not
applied; AND/OR and groups are drawn as structure; the help and key lines follow the focus; the footer
shows a live match count. Advanced mode (the grammar, with completion) adds a cheat sheet, a caret under
parse errors and says why it cannot switch back to Guided. Commands are on Ctrl and F keys so letters
always type (Ctrl+N add, Ctrl+D remove, Ctrl+O AND/OR, Ctrl+G group, F2 mode, F10 apply); Esc asks
before discarding changes (067d97c).
- **Records**: sections by tier, links, child lists, next and previous through the list a record came
from, create, edit and copy forms with an editor per field type, contingent pickers that narrow, local and
server validation by field, file uploads and BLOB downloads through a file navigator, audits (`A`) and
tags (`t`).
- **Record collaboration** (collaboration extension), at the bottom of the record view so the record starts
right under its title with the cursor on its first field: a dim strip above the key line with the tags,
comments, attachments, open tasks, whether you follow it and who is viewing now with their keys (`Tags: none
(t) · 0 comments (C) · … · viewing: Admin (you)`; others viewing or editing replace "(you)" with an accent;
segments drop whole when the width is short), and from 140 columns a right-hand Collaboration panel (view
setting `collaboration panel: auto/on/off`) with the latest comments, the next due task, the followers and
each viewer's state. In `a`/F9 (keys `C` `P` `K` `T` `W`): comments (list, add, reply), follow and stop
following, attachments (list, download through the file navigator, upload, delete), the timeline, tasks
(list, add, done) and the share preview (who could read the record, then send). Strip segments and panel
rows are click zones. Without the extension F9 says "needs the collaboration extension" (b782812, 3e80043).
- **Developer view** (`D` on a record or a grid) in sections (Security, Events, Scripts, JSON, Metadata,
Audits, APIs, then any further developer panel; ←→ or 1-9) over a scrollable pane: tables in the grid's
column layout, JSON pretty-printed and tinted (`v` opens the value viewer, `y` copies), web links turned
into TUI actions or shown as browser only, a record's field values, API endpoints, audits and script test
runs, a table's ESB counters, metadata and JSON; F3 goes to the record or the grid (f236c7b).
- **Processes**: the wizard draws every step as the web does (help, HTML, view values, records, the
validation review, widgets, downloads), with back steps, record limits, progress with elapsed time and
cancel.
- **Bulk load and bulk changes**: a wizard for bulk insert and bulk edit from a file (upload through the
file navigator, a column mapping grid with typed defaults and field help, value mapping, review with
optional validation, result), bulk edit of switched-on fields, bulk delete, saved bulk-load profiles and
the upload template.
- **Reports, saved reports and sharing**: run reports (process reports and streaming ones), edit a saved
report's filter, columns and pivot, and share records of shareable tables (`Z`, `:share`) with users or
groups.
- **Sign-in in the UI**: TABLE_BASED username and password, OAuth2 in the browser (authorization code with
PKCE, the device flow as the alternative) and Auth0; sign-out ends the server or provider session; a
session that expires mid-use opens sign-in on top and returns to the screen.
- **Widgets that act**: dropdowns and date pickers, parent widgets with children in a grid or tabs, process
widgets, data bag versions, script revisions, export, ad hoc buttons and inputs, dynamicForm editing and
composite widgets; dashboards and widgets drawn in block characters (stats, bar charts, sparklines,
percentage bars, tables, steppers); the ESB overview as `:esb overview` shows it (destinations,
publishers, triggers, counters; Enter on a destination offers its table's ESB, messages and dead
letters); disabled tables, processes and apps explain the denial, and capabilities gate new, edit,
delete and copy (19cc3ec). Text and HTML widgets (Quick Search Admin's Search Diagnostics, the SQL Workbench
summary, the alerts cards) are drawn as a document with the web's hierarchy: headings in the accent colour,
leading status words (OK, WARN, FAIL, DEGRADED, DOWN, ...) as fixed-width coloured badges, `Label: value`
items as an aligned two-column list, ISO timestamps in the profile's date format, paragraphs wrapped at
about 100 cells, enumerations as bullets, button rows of links as items that Enter follows (ASCII badges
and bullets under the ASCII setting) (d7c6e65).
- **Extension operations on the `:` line** (`:alerts get-inbox`, `:api <operationId>`); widget payloads
without a terminal block show their JSON as key/value rows and sub-tables, and `J` shows the raw JSON.
- **Dates: type them or pick them**: one date control for DATE and DATE_TIME fields in create and edit
forms, process forms, bulk edit and the filter dialog's value cell. Typed entry shows the value in the
profile's display format with the format hint (and zone), and reads ISO, the display format, partial
dates (`2026-3-5`, `3/5`), `t`/`today`, `n`/`now` and the filter grammar's `now(-7d)` and
`startOf(month)`; a date that does not exist is refused with the reason and the field keeps the focus.
F2 or Ctrl+Space opens a month calendar anchored to the field (arrows, PgUp/PgDn, Shift a year, Home/End,
`t` today) with a time row for DATE_TIME. `:dates order ymd|mdy|dmy clock 24h|12h week monday|sunday
weeknumbers on|off enter picker|next` sets the format per profile. Values go out as before: `YYYY-MM-DD`
and DATE_TIME as the UTC instant of the local time, bulk edit included (ad4001d).
- **Printing**: **Ctrl+P prints what you see on every screen** (also `p` on records and grids, F8 where the
screen has no Delete, `:print [printer]`, `a`, F9 and the Print Screen key where the terminal passes it
on). A record view prints its record sheet; a grid prints a table list with the columns, widths, filter
and sort shown, choosing all rows, this page, or the selection as a list or as record sheets; any other
screen prints its text as a monospace PDF. A printer picker shows unprintable printers with the reason
and preselects the last used one; jobs are followed in the title bar and status line, and `:printjobs`
lists recent jobs. Without printing, Ctrl+P saves instead (the grid's export, else the screen text). No
Print action is offered without the extension (acfb090, 555c434).
- **SQL Runner** (sql-workbench extension), from the SQL Workbench app, `/`, F9 › Work and `:sql`: an SQL
editor with syntax tint, F5 / Ctrl+Enter running the statement under the cursor, completion popups
(tables after FROM, columns after SELECT, keywords and recent SQL), history; a server-paged,
server-sorted result grid drawn like the main grid; F8 / `x` export every row as csv, tsv, xlsx or json
or copy it, `y` `Y` Ctrl+Y copy the cell, row or result; errors inline with their SQL position; EXPLAIN as
a plan tree (F6); a schema browser (F3); notebooks with SQL and markdown cells (F2, F4); quick text bar
charts; the audited run history with re-run (F7). Read-only runs; hidden, and `:sql` explains why, when
the server lacks the extension (cc78396, efc202a). The completion popups of the SQL Runner and the `:` line take the width they need, in two
columns, with wrapped descriptions (70b6fa1).
- **The mouse, everywhere** (on by default; F9 › Settings › Mouse, `:mouse on|off`, saved in `ui.mouse`;
`--no-mouse` / `--mouse` for one run): a click selects or focuses, a double-click activates, a right
click opens the actions at that row, the wheel scrolls the pane under the pointer, a drag on a grid
column's edge resizes it and on its header moves it, the F-key bar and the footer's server are
clickable, dialogs, menus, the SQL Runner and the date picker take clicks. With the mouse on, the
terminal selects text with Shift+drag (cbabc31).
- **A live screen saver** (`:lock`, the idle timeout, `:saver [style]`): tiles refreshed every 10 to 30 s
for people online, jobs, health, database sessions, ESB depth and dead letters, unread alerts, realtime
events per minute, ping and favorite table counts, each hidden when its extension is absent and dimmed
when stale; four styles (Bounce, Starfield, Flip clock, Big numbers, or auto) starring Query, the qrun.io
mascot, whose block-letter logo says the connected profile's name (short name up to 6 characters) with
the server host beneath; at most 4 frames a second; with a PIN set the prompt opens over the saver
(c22ae75, 899326a). Each style is one compact group at most about 90 columns wide that moves as a unit
(Big numbers: number, Query, caption and tiles as one centred stack; Flip clock: tiles and Query with the
clock; Bounce: tiles beside the logo, stacked under it on a narrow terminal), with the clock line and the
lock line just above and below it (aff2b00, 98d2076). Against burn-in every element moves (the header and
lock line change place every 30 s, the flip clock drifts, Big numbers changes corners) and the screen dims
after 10 minutes (`:saver burnin on|off`, `:saver dim <minutes|off>`) (899326a).
- **Clipboard copy of CLI equivalents**: the "CLI equivalent" dialog copies its command with `y`, `c` or
Enter, the filter dialog has Ctrl+Y, and F9 › Settings › "Copy CLI commands to the clipboard
automatically" (`ui.copyCliCommands`, default off) copies as soon as the dialog opens (e8f075a).
- **History screen** (`:history`, F9 › Work › History): the activity log of the profile, newest at the
bottom; typing filters, Enter does an entry again (runs the `:` command, opens the record, the table with
its filter, the process), `y` copies the CLI equivalent, `c` or F8 clears. The TUI records its `:`
commands, opened apps, tables and records, queries with filters, record creates, edits and deletes (no
values), process runs and outcomes, exports, prints, server switches, sign-in and sign-out. Ctrl+R on the
`:` line is a bash-style reverse search over these commands (Ctrl+R again for older matches, Enter runs,
Esc edits) (0bcc735).
- **Column statistics fallback**: `S` on a table without the server capability (or when the server
answers 403 or 404) computes count, nulls, distinct, min, max, sum, mean, median, text lengths and top
values from the rows the grid has loaded; the dialog says so and `a` loads up to 10,000 rows (e5d6c92).
- **Help and care**: F1 shows the application's help for the app, widget, table, process or step you are
on, and the grid footer shows the column's help; searching ignores case (a term starting with `=`, or
Match exact case, asks for exact case); themes per profile (high-contrast, classic, light); `qqq ui
--check` (and a quick check at startup) finds tmux key and colour problems and offers fixes; `--ascii`,
`--linear`; Ctrl+C or a crash always restores the terminal. A soak test drives the real UI in tmux with
random keys.
- `packages/tui/scripts/smoke.sh --server URL [--profile NAME]` runs the tmux smoke test against any
server in an isolated config (6950e9d, f149a47).
- **Assistant panel** (assistant extension): `Ctrl+G`, `:ask [question]` or F9 › Work › Assistant opens a
chat pane on the right (the whole body below 110 columns): the question line at the bottom, the
conversation above with each answer's numbers and links; Tab moves over the links and Enter opens the
record or grid (beside the panel; a full-width panel closes so the screen shows). Questions carry the
record or grid (with its filter) under the panel as context, as the Next UI sends it (Ctrl+D drops it);
↑↓ recall this profile's questions; Ctrl+L starts a new conversation, Ctrl+X stops an answer, Esc closes
(the conversation is kept). Without the extension the entry is disabled and Ctrl+G says what is missing;
when the assistant is off the panel shows the server's reason and what to configure (2c70c3c).
#### Kernel, client and conformance
- **`@qrun-io/qqq-core`** (new): everything that is not a terminal, shared by the CLI and the UI: output
formats with JMESPath `--query`, the filter grammar, an operation index generated from the OpenAPI
document, the form engine, the process runner, records and associations, the widget render model, the
metadata cache, the extension registry, profiles and the keyring, `Printing` (printers, devices, jobs,
print, cancel, watch; `printTableList`, `printText`), `formatDocument` for extension output, and the
date parser and formatter.
- **`@qrun-io/qqq-ui-api-client`** is generated from UI API 1.8.0 merged with 14 extension documents (219
operations, each tagged with its extension, including the 17 printing 2.0.0 operations, which are the generated `qqq
printing ...` subcommands for print agents; printing 2.1's table-list and text printing are processes the kernel
runs, reached as `qqq print list` and `qqq print text`, not as `qqq printing` subcommands), and gains PKCE, revocation and end-session helpers (5cf6243).
- **Conformance**: the suite checks `GET /qbit/printing/session` and the printing tables when a server
lists printing (05f467c); `qqq-dev-idp` gains the authorization code with PKCE (Approve/Deny page),
revocation and end-session.
- **Assistant** (`extensions/assistant.ts`): `Assistant.config()` (the feature probe, `installed: false`
without the extension) and `Assistant.ask(question, { context, conversationId, onEvent, signal })`,
which creates a conversation with the page context, reads the turn's server-sent events as they arrive
(`SseParser`) and folds them into an answer (text, numbers, links, tools, usage); `parseAppHref` and
`assistantContextFor` map app paths and pages both ways (a5e0cf4, e5327b2).
#### Packaging, CI and development
- **Distribution**: one single-file `qqq` binary per platform with the terminal UI bundled, for macOS arm64
and x64, Linux x64 and arm64 (glibc and musl) and Windows x64 (`npm run binaries`, `--cli-only` without
the UI); a curl installer that verifies `SHA256SUMS` and installs the newest stable `cli-v*` release
(`--pre` or `QQQ_PRE=1` takes prereleases, `--dry-run` prints the download, `--help` works piped from
curl) (a14cd5c); a Homebrew tap and a Scoop manifest that pass their CI and lint (5563f2f); `npm run
release` with the readiness gate, `scripts/verify-binary.sh`/`.ps1` and `docs/release-runbook.md`.
- CI turns on the macOS arm64 and Windows x64 lanes of qqq-orb 0.8.33 (0d62416); the package tests run
without shell globs for the Windows lane (5dd5e2d).
- **Enterprise test server**: `scripts/enterprise-server.sh` starts a local enterprise QQQ server with a
MinIO container for object storage (6018cdf), and `scripts/enterprise-smoke.sh` sweeps the CLI over its
qbits (cf0e610); `scripts/print-agent.sh` runs qqq-print-agent's virtual file printer against a local
server (fc47cb8). The enterprise profile gives the assistant a model provider: Claude when `ANTHROPIC_API_KEY` is
set in the private env file `~/.local/state/qqq-enterprise/assistant.env` (yours, not a symlink, mode 0600; read
by name, never sourced or printed, exported only into the server's environment), else a local Ollama; `--status`
and `--assistant-status` show which model answers or why none does (29f3559).
- **Documentation**: npm READMEs for every package, a rewritten repository README, `docs/README.md`,
CONTRIBUTING, SECURITY and CODE_OF_CONDUCT, issue and pull request templates, CODEOWNERS and Dependabot
(e46bc6b, 1b0d547, ac86860, 7fed559); TRY-IT as the 1.0.0 walkthrough (cddbea7).
### Changed
- **Default export names carry the local date and time**: `person_2026-11-01_102316.csv`, not `person.csv`,
for `table export`, `widget --csv`, report downloads, field saves from a record, and every TUI export, save
dialog and screen-text capture (`<name>_<YYYY-MM-DD>_<HHmmss>.<ext>`, 24-hour, no colons). A name you give
(`-o`, a name typed in the file dialog) is used exactly as given, and a name the server chooses is kept
unless it is taken, when the stamp goes before the extension (c201f92).
- **One meaning for `-o` across the CLI: `-o`/`--out` is always the output file; the sort is `--order`**
(no short form). `table export -o people.csv` used to sort by a field named `people.csv` (the server
answered 403); it now writes the file. Migration: write `--order` wherever `-o` meant the sort. On the
commands that write no file (`table query`, `saved-view save`, `saved-report preview` and `set`), `-o`
still sorts until 1.1, with a deprecation note on stderr; a sort field that looks like a file path
(`out.csv`, `./x`) is warned about; completion offers fields after `--order` only (0687083).
- Changed from 0.1: sign-ins move from `~/.config/qqq-cli/credentials.json` to named profiles
(`~/.config/qqq/config.json`) with secrets in the OS keyring; 0.1 sign-ins are imported once, and the old
file is renamed only after the keyring took every entry. The global `--server` and `--version` have no
short form any more (`-s` and `-V` collided with `--set` and `--view`).
- `qqq login --server URL` against an existing profile now asks first (see Fixed). `qqq doctor` words its
UI API support from the same range as `--version` (1fd06db).
- The UI's search and help are separate: F2 is Search, `/` finds anything, F1/`?` is only the keyboard
reference, and F9 no longer lists every app, recent record and metadata match (70c064e, ef2b554).
- The UI's F9 menu, filter dialog, developer view, date entry and record view were redesigned (see Added
and Fixed); Ctrl+P no longer steps to the previous record (PgUp and `[` still do; Ctrl+N still steps to
the next) because it prints (555c434).
- The conformance suite authenticates every authenticated check the same way, as the CLI does: a JWT as a
bearer token, a session UUID also as the `sessionUUID` cookie, and on MOCK and FULLY_ANONYMOUS servers
without a token a server session from `manageSession`; its metaData check names the authentication used
(e7108ad).
- `fast-uri` 3.1.9 (transitive, through ajv in the conformance package) for four URI parsing advisories
(0845036).
### Fixed
- Links with a base64 `filter` parameter (the form the web UI and the assistant write) opened the whole table:
the UI's link reader and the kernel's `parseAppHref` now decode it, JSON still works, and a filter without
criteria counts as an empty one (e5327b2).
- Output piped into a reader that exits early (`qqq table query person | head -1`, exports, process logs,
`assistant ask`) ends quietly with status 0 instead of an unhandled `write EPIPE` stack trace (7c66b16).
- `qqq login --server URL` with `QQQ_PROFILE` (or `--profile`) naming an existing profile of another
server silently pointed that profile at the new server. It now warns and needs `--yes` (or a yes at the
question on a terminal); `--profile <new-name>` adds a profile instead. A token refresh under a one-off
`--server` no longer moves the profile either (a10c834).
- `qqq esb messages <topic>` without `--trigger` names the topic's triggers in the error, and `--trigger`
takes the process name too; before, the server's "name one of the triggers" did not say which and a
process name answered 404 (248f30f).
- `qqq health get-summary` on a server that serves `/health` without advertising the health extension shows
that endpoint's checks in the summary shape, with a note, instead of "not installed" (248f30f).
- The UI's record view keeps its layout when paging record to record: each child list is a box of fixed
height (5 data rows by default, padded when shorter, scrolled inside with "rows 1-5 of 12" when longer),
empty lists still draw their box, fields and columns are placed from the metadata, and the row count is a
per-table view setting (`v`, `childRows`) with a default in `ui.childRows`. The `v` dialog is a settings
form like the grid's View settings: Columns cycles auto, 1, 2; Label width (8-60) and Child list rows (1-50) take typed
digits or +/- and Backspace returns them to auto or the profile default; one Collaboration panel row cycles auto, on,
off; `r` resets with a confirm; `*` marks values changed from the default An association a section's
`childRecordList` widget lists shows once, in the widget's place and under its label (0f7f536).
- The UI's filter dialog reads escaped quotes like core's grammar (213c605); before the redesign a
true/false quick filter could be set to true but not changed from the quick row (067d97c).
- The UI's jobs widgets showed each statistic's web address; the alerts setup widget ran its columns
together; HTML entities such as `&middot;` were not decoded; the ESB app showed "(ESB_OVERVIEW: no
terminal rendering yet)" (19cc3ec).
- No text in the UI runs outside its container. Lines are cut to the cells they have, measured in terminal
cells (wide characters count double), with `…` at the cut; dialogs are held inside the screen and their
lines default to the dialog's inner width. View settings shows only the value in each row (Pinned columns
`0`, not the explanation) and explains the selected row on a help line; its key help breaks between keys
in at most three lines, dropping the least important first; `+`/`-` change Pinned columns as the help
said. Screen footers, the record view's, F9's, the Servers dialog's, the SQL Runner's, the date picker's
and message dialogs' included, drop whole keys instead of cutting one in the middle. Process errors wrap
to the pane, the home screen's header lines end in `…`, and a column width saved wider than the terminal
is held to the grid. `V` shows the whole value of the grid cell, or of the record view field row, under
the cursor. `packages/tui/scripts/overflow-sweep.sh` checks every screen and dialog at 80/100/120/200
columns by 24/50 rows, and `smoke.sh` checks its captures the same way (4abfd8d, c94499c, 0e4716a,
da76a35, c2a9e98, 3bc2179).
- App cards showed the app's Material icon name instead of a glyph (0691e6f).
- Quitting the UI after the terminal was gone printed "setRawMode failed with errno: 2" and a native
shutdown error (e13e7bf).
- A record found by the UI's search opened with an empty key (the server's field is `recordId`) and was
listed last (70c064e).
- Process help markup is stripped with the core `stripTags` (c5b4b47).
- The developer view drew the ESB sample event as broken fragments (minified JSON went through the prose
word-wrap); code blocks are pretty-printed and drawn one source line per screen line.
- `npm test` runs on Node 20; a question closed with Ctrl-D no longer hangs on Node 22.12; presence tests
wait on state and have deadlines (16be5c2); the PIN storage test no longer flakes (7d754bf); the metadata index test guards by scaling and the dashboard
widget test asserts concurrency, not wall-clock bounds (e3822d2, 508ddc0).
- The UI's grid draws adorned cells like the record view (REVEAL values stay masked, HTML as text, chips in
brackets); the create form asks for a text primary key and leaves out generated numeric keys.
- The hardening review (`docs/review-2026-10-31.md`): option collisions, `table update` on contingent
fields, corrupt config and secrets files no longer overwritten, awaited 0.1 imports, Ctrl-C and Ctrl-D at
prompts, REVEAL values masked in query cells, one DATE_TIME, help and download path, checked numeric
options and file arguments.
- `smoke.sh` skips the sample-only record steps against another server (f149a47).
### Security
- Links in the UI's widgets and server HTML are content: Enter opens a table (with a filter that has the
shape of a query filter), a record (a plain key), a process or an app only when the metadata knows it, and
shows the address of anything else without opening a browser. Text starting with `:` in a server link is
shown, never run. Only links the TUI builds itself (ESB destination rows, keyed so server JSON cannot
forge them) run commands; the command is shown first, server names are quoted, and a command that changes
data asks first (c35def5).
- `scripts/enterprise-server.sh` keeps its MinIO credentials, pid file, log and data in a private state
directory (`${XDG_STATE_HOME:-~/.local/state}/qqq-enterprise`, mode 0700), uses an existing file only if
you own it, it is not a symlink and the credentials are 0600, checks that a pid file holds a number before
signalling it, and parses the credentials file for its two keys instead of sourcing it (3fa81bf).
- Fixes for the 21 CodeQL alerts raised on the release pull request:
- `qqq table edit` and `qqq developer` no longer build the editor's shell command from the file name; a
primary key holding shell syntax (such as `$(...)`) ran as a command when `$EDITOR` opened. The path is
passed to the shell as `$1` (POSIX) or as a quoted variable (Windows) (4d13ba9).
- `qqq-dev-idp` end-session redirects only to a loopback `post_logout_redirect_uri`, rebuilt from its
parts, and answers 400 for any other (9609acf).
- Filter values escape backslashes as well as double quotes when printed or completed, and the parser
and tokenizers read `\\` and `\"` back (e1694d4).
- HTML help and widget text becomes terminal text in one linear pass; entities are decoded once, so
`&amp;lt;` shows as `&lt;` (e1694d4).
- Regular expressions that took quadratic time on long input are replaced with linear scans: slash
trimming in API paths, BLOB sizes, `${name}` interpolation and `${input.x}` dependencies, filter error
positions, `tmux list-keys` parsing (e1694d4), the search screen's status-line clock time and the date
parser's split of a date from its time (9535e3a).
- The release helper escapes every sed metacharacter in the version it puts into its notes command
(d749f14).
### Known limitations
- Jobs need a durable-jobs server. Auth0 sign-in (CLI and UI) is built but has not met a real Auth0
tenant (finding 28, qqq-cli#10). See `docs/server-findings.md` for the server-side issues the clients
work around, including case-insensitive text filters (finding 27).
- The binaries are not Developer ID signed or notarized (macOS: Gatekeeper prompt for browser downloads,
`xattr -d com.apple.quarantine` clears it) nor Authenticode signed (Windows: SmartScreen warning). The
Windows and macOS x64 binaries are cross-compiled and were not run before this release.
- The npm package is the CLI only; `qqq ui` comes with the binaries.
- Printing needs the printing extension (qbit-printing 2.x) and Search the quick search extension;
without them the UI offers no Print action and F2 searches names and the current table.
- Parity with the web UI (`docs/parity/feature-matrix.md`): the CLI has no open row (290 pass, 107 n/a, 4
blocked on cited server findings) and the UI has none open (331 pass, 67 n/a, 3 blocked, SEC-030 on
qqq-cli#10 and qqq#1359 among them). `npm run release -- --check` reports READY.
## [0.1.0]
The first CLI: device-code sign-in, apps, tables, filtered queries, records, processes, jobs and a live
event stream, on the generated UI API client.
+79
View File
@@ -0,0 +1,79 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- **Demonstrating empathy and kindness** toward other people
- **Being respectful** of differing opinions, viewpoints, and experiences
- **Giving and gracefully accepting** constructive feedback
- **Accepting responsibility** and apologizing to those affected by our mistakes
- **Focusing on what is best** for the overall community
Examples of unacceptable behavior include:
- **The use of sexualized language or imagery**, and sexual attention or advances of any kind
- **Trolling, insulting or derogatory comments**, and personal or political attacks
- **Public or private harassment**
- **Publishing others' private information**, such as a physical or email address, without their explicit permission
- **Other conduct which could reasonably be considered inappropriate** in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **contact@qrun.io**. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).
## Contact Information
- **Community Leaders**: contact@qrun.io
- **Security Issues**: security@qrun.io (see [SECURITY.md](SECURITY.md))
- **Organization**: [QRun-IO](https://github.com/QRun-IO)
Contributing to this repository: [CONTRIBUTING.md](CONTRIBUTING.md).
+83
View File
@@ -0,0 +1,83 @@
# Contributing to qqq-cli
Thanks for helping. This repository holds `qqq` (the CLI and the terminal UI) and the TypeScript packages
it is built on. Bugs and ideas go in [issues](https://github.com/QRun-IO/qqq-cli/issues); problems in the
QQQ server or the UI API itself belong in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues).
Security problems: see [SECURITY.md](SECURITY.md), never a public issue.
## Layout
| Path | What | Toolchain |
|---|---|---|
| `packages/ui-api-client` | the typed client, generated from `openapi/qqq-v1.json` | npm workspace, TypeScript |
| `packages/core` | the client kernel (no terminal code) | npm workspace, TypeScript |
| `packages/conformance` | the conformance suite and `qqq-dev-idp` | npm workspace, TypeScript |
| `packages/cli` | `qqq` | npm workspace, TypeScript, commander |
| `packages/tui` | `qqq ui` (OpenTUI on Bun); private, bundled into the binaries | Bun, its own `bun.lock` |
| `scripts/` | spec merging, code generation, lint, binaries, release, packaging, install | Node, sh |
| `docs/` | see [docs/README.md](docs/README.md) | |
## Setup
- Node.js 20 or later (CI runs 22) and npm. The workspaces share the root `package-lock.json`.
- Bun 1.4 or later, for the TUI and for building binaries (`brew install bun`, or https://bun.sh).
- For trying changes against a real server: the QQQ sample server (Java 21, Maven), see [TRY-IT.md](TRY-IT.md).
`npm run mock` serves the API document with Prism on http://127.0.0.1:4010 when no server is at hand.
```sh
git clone https://github.com/QRun-IO/qqq-cli && cd qqq-cli
npm ci
npm run build
node packages/cli/dist/main.js --help # the CLI from the checkout
node packages/cli/dist/main.js ui # the TUI from source (runs Bun)
```
## Checks
Every pull request must pass, locally and in CI:
```sh
npm run build && npm test && npm run lint # lint: license headers, no cross-package src imports, version consistency
npm run test:tui # when packages/tui or the kernel changed
npm run typecheck:tui # likewise
```
When the API document changes (`npm run spec:merge` or `npm run spec:merge-extensions`), run
`npm run generate` and commit the regenerated `packages/ui-api-client/src/schema.ts` and
`packages/core/src/api/operations.ts`; CI fails when `schema.ts` drifts and `scripts/release.mjs` checks both. Do not edit generated files by hand.
## Code
- TypeScript, ESM, strict. Each package imports the others by package name, never from another package's
`src/` (lint enforces it).
- Every source file starts with the license header lint checks:
`Copyright 2026 QRun.IO, Inc.` / `Licensed under the Apache License, Version 2.0 (see LICENSE).`
- Nothing about an application is hard-coded: tables, processes, labels and capabilities come from the
server's metadata, and unknown enum values or fields are tolerated. Every call is a spec operation.
- `@qrun-io/qqq-core` and the CLI use no Bun-only API; the TUI uses OpenTUI and only draws what the
kernel produces.
- Tests sit next to the code in `src/test` (`node --test`) and `packages/tui/test` (`bun test`). New
behaviour comes with a test; a bug fix with a test that failed before it.
- Commands, output shapes and exit codes are a public interface. Changing one is a breaking change: say
so in the pull request and the CHANGELOG.
## Commits and pull requests
- **Signed commits are required on `main`** (`git commit -S`; set up SSH or GPG signing in GitHub). Unsigned
commits cannot be merged.
- Conventional-commit subjects (`feat(cli): ...`, `fix(core): ...`, `docs: ...`), with a body that says why.
- Branch from `main`; one topic per pull request. Fill in the pull request template, including how you
tested against a server.
- User-visible changes get a line in [CHANGELOG.md](CHANGELOG.md) under the next version, and new or
changed commands are documented in the README, TRY-IT.md or the package README.
`node scripts/readiness.mjs` checks that every `qqq` command in README.md and TRY-IT.md exists.
- Do not bump package versions in a feature pull request; releases do that
([docs/release-runbook.md](docs/release-runbook.md)).
- [CODEOWNERS](.github/CODEOWNERS) requests the maintainer's review on every pull request.
## License
By contributing you agree that your contribution is your own work and is licensed under the
[Apache License 2.0](LICENSE), the license of this repository.
Everyone taking part follows the [Code of Conduct](CODE_OF_CONDUCT.md).
+292 -98
View File
@@ -1,137 +1,331 @@
# qqq-cli
# qqq
The reference command-line client for [QQQ](https://github.com/QRun-IO/qqq) applications, and the kit for writing your own CLI, TUI or native client. Everything here talks to a QQQ server only through the **UI API** (`/qqq/v1`), the API the web UI uses too.
`qqq` is the command-line client and full-screen terminal UI for [QQQ](https://github.com/QRun-IO/qqq)
applications. It talks to a QQQ server only through the **UI API** (`/qqq/v1`), the same API the web UI
uses, so apps, tables, fields, processes, widgets and extensions all come from the server's metadata and
one binary works against any QQQ application.
| Package | What it is |
|---|---|
| [`@qrun-io/qqq-cli`](packages/cli) | `qqq`, the reference CLI: device-code sign-in, apps, tables, filtered queries, records, processes, jobs, and a live event stream |
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | A typed TypeScript client, generated from the merged UI API document (core plus extensions). It also does OAuth2 device-flow sign-in, token refresh, and reads the realtime (SSE) stream |
| [`@qrun-io/qqq-ui-api-conformance`](packages/conformance) | A black-box conformance suite you can run against any server, plus `qqq-dev-idp`, a local OIDC provider for testing native clients |
This repository also holds the TypeScript libraries `qqq` is built on, for anyone writing their own CLI,
TUI or native client. Current release: **1.0.0** ([release notes](docs/release-notes/1.0.0.md),
[CHANGELOG](CHANGELOG.md)).
The packages are published to GitHub Packages:
- [Install](#install)
- [The CLI in 60 seconds](#the-cli-in-60-seconds)
- [The terminal UI in 60 seconds](#the-terminal-ui-in-60-seconds)
- [Compatibility](#compatibility)
- [Packages](#packages)
- [Documentation](#documentation)
- [Development](#development)
## Install
Every install path below except npm ships the same single-file binary with the CLI and the terminal UI
(`qqq ui`); no Node or Bun is needed. Builds exist for macOS (arm64, x64), Linux (x64, arm64; glibc, and
musl for Alpine) and Windows (x64).
**macOS and Linux, curl installer** (to `~/.local/bin`; checks the download against the release's
`SHA256SUMS`). It installs the newest stable release, the same channel as Homebrew and Scoop;
`--version` pins one, `--pre` takes prereleases too, and `--dry-run` shows what it would download:
```sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0 --dir /usr/local/bin
```
**Homebrew** (macOS and Linux, from [`QRun-IO/homebrew-tap`](https://github.com/QRun-IO/homebrew-tap);
installs bash, zsh and fish completions too):
```sh
brew install qrun-io/tap/qqq
```
**Scoop** (Windows, from [`QRun-IO/scoop-bucket`](https://github.com/QRun-IO/scoop-bucket)):
```powershell
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket
scoop install qqq
```
**Direct download**: pick `qqq-<os>-<arch>[-musl][.exe]` from the
[releases](https://github.com/QRun-IO/qqq-cli/releases) and check it against `SHA256SUMS`. On Alpine,
the musl build needs `apk add libstdc++ libgcc`.
**npm, CLI only** (GitHub Packages, Node.js 20 or later; no `qqq ui`, which says where to get it):
```sh
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@rc
npm install -g @qrun-io/qqq-cli
```
Requires Node.js 20 or later and a QQQ server with UI API 1.6.0 or later (QQQ 4.1.0-RC.15 or later).
GitHub Packages asks for a token even for public packages: `npm login --registry=https://npm.pkg.github.com`
with a GitHub token that has `read:packages`.
## The CLI
**macOS, downloaded with a browser:** the binaries are not notarized yet, so Gatekeeper stops a binary a
browser downloaded ("cannot be opened because Apple cannot check it for malicious software"). Clear the
download flag once with `xattr -d com.apple.quarantine ./qqq-darwin-arm64` (or right-click, Open, in
Finder). Homebrew and the curl installer do not set the flag and are not affected.
**Windows:** the `.exe` is not Authenticode-signed yet, so SmartScreen may warn on first run (More info,
Run anyway). Scoop installs are not affected.
Check the install with `qqq --version` (it names the UI API range and whether the binary has the UI) and
`qqq doctor`.
## The CLI in 60 seconds
The grammar is `qqq <noun> <verb> [arguments] [options]`, with global options anywhere. The examples run
against the QQQ sample server on `http://localhost:8077` (MOCK authentication, so no password); point
`--server` at your own server instead.
**Sign in.** OAuth2 and Auth0 servers use the device flow (or `--browser` for authorization code with
PKCE on a loopback redirect), TABLE_BASED servers ask for a username and password, MOCK and fully
anonymous servers get a server session. The first sign-in becomes the default profile.
```console
$ qqq login --server https://admin.example.com
To sign in to https://admin.example.com, open:
https://auth.example.com/device?code=ED84-CC03
and confirm the code ED84-CC03 (it expires in 10 minutes).
Waiting for approval...
Signed in to https://admin.example.com as darin@example.com.
$ qqq tables --app peopleApp
name label insert edit delete
------- -------- ------ ---- ------
person Person yes yes yes
pet Pet yes yes yes
$ qqq query person --where "firstName ~ a" --where "id < 100" --order -id --limit 5
Id First Name Last Name Annual Salary ...
5 Morgan Sample $1,500,000.00
3 Casey Sample $300,000.00
(2 records)
$ qqq get person 1
$ qqq run greetInteractive --records 1,2 --value greetingPrefix=Hi
$ qqq jobs --all
$ qqq tail --channel table:person
$ qqq login --server http://localhost:8077 --profile sample
Signed in to http://localhost:8077 (mock authentication) as profile "sample"; sign-in kept in the macos-keychain.
$ qqq whoami
key value
------------ ------------------------------------------------------------
profile sample (default)
server http://localhost:8077
auth mock session
apiVersion 1.8.0
extensions esb 1.1.0
apps 6
...
```
| Command | UI API operations |
**Query a table with a filter**, then **read one record**:
```console
$ qqq table query person -w "id <= 3" --fields id,firstName,lastName,email
Id First Name Last Name Email
-- ---------- --------- ---------------------
1 Avery Sample avery@example.invalid
2 Blair Sample blair@example.invalid
3 Casey Sample casey@example.invalid
(3 records; filter: id LESS_THAN_OR_EQUALS 3)
$ qqq table get person 1
Section Field Value
--------------- ------------- ---------------------
Identity Id 1
Identity First Name Avery
Basic Info Email avery@example.invalid
Employment Info Annual Salary $75,003.50
...
associations: pets (--associations lists the child records)
```
**Run a process** (every step screen is supported; values come from `-v`, records from `-r`, and
anything missing is asked for):
```console
$ qqq process run greetInteractive -r 1,2 -v greetingPrefix=Hi
Greet Interactive: starting
No Of People Greeted: 2
Id First Name Greeting Message
-- ---------- ----------------
1 Avery Hi Avery null
2 Blair Hi Blair null
(2 records)
```
**Export** to CSV, XLSX, JSON or TSV:
```console
$ qqq table export person -w "id <= 5" --fields id,firstName,email -o people.csv
wrote people.csv (206 bytes, text/csv;charset=utf-8)
```
More of the grammar (`-o`/`--out` is always the output file; the sort is `--order`, prefix `-` for descending):
```sh
qqq table query person -g "(firstName = Avery OR firstName = Blair) AND birthDate > now(-40y)"
qqq table query person -w "lastName STARTS_WITH S" --order -id -l 5 # order by id descending, 5 rows
qqq table create pet --set name=Pixel --set personId=2 --set speciesId=Dog
qqq table bulk-insert person --file people.csv --dry-run # map, validate and review; --yes loads
qqq home # what you may open, with the command for each
qqq find person # the command palette as a command
qqq api getTableMetaData --path tableName=person -Q 'fields.*.label' # any of the 219 operations by id
eval "$(qqq completion zsh)" # also bash, fish and PowerShell
```
| Noun | Verbs |
|---|---|
| `login` | `getAuthenticationMetaData`, then the provider's device-authorization grant with `values.nativeClientId` |
| `whoami` | `getVersion`, `getMetaData` |
| `apps`, `tables [--app]` | `getMetaData` |
| `query <table> [--where "field OP value"]... [--or] [--order [-]field] [--limit n] [--fields a,b]` | `getTableMetaData`, `queryTable` |
| `get <table> <pk>` | `getTableMetaData`, `getRecord` |
| `run <process> [--value k=v]... [--records ids]` | `getProcessMetaData`, `initProcess`, `stepProcess`, `getProcessStatus` |
| `jobs [--all]` | `listJobs` |
| `tail [--channel ...]` | `openRealtimeStream`, `updateRealtimeSubscriptions` (`user:me`, plus your channels) |
| `table` | `list`, `describe`, `query`, `count`, `stats`, `get`, `create`, `copy`, `update`, `delete`, `export`, `children`, `download`, `variants`, `columns`, `actions`, `bulk-insert`, `bulk-edit`, `bulk-delete`, `apis`, `api-doc`, `api-call`, `developer`, `widget-form` |
| `process` | `list`, `describe`, `run` (every step screen, async jobs with progress, Ctrl-C cancels on the server) |
| `home`, `find`, `apps`, `search`, `tail`, `whoami` | the landing page, the command palette, app homes (`--widgets`), global search, the live event stream, who you are |
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports and saved reports (pivots, schedules), saved views, sharing, saved bulk-load mappings, audit history, dashboard widgets as text, associated scripts, jobs, preferences, help |
| `api <operationId>` | any UI API operation by id: the escape hatch |
| `history` | everything you did in the CLI and the TUI on this profile, like a shell history (`history rerun <n>`, `history clear`, `--since`, `--grep`) |
| `print`, `printer` | print records (`print person 1 2`), table lists (`print list person -w ...`), text (`print text`), follow and cancel print jobs (`print job list\|status\|cancel`), printers and print agents (`printer list\|use\|show\|forget\|devices`); needs the printing extension |
| `extensions`, and one noun per extension | `esb`, `collab`, `health`, `alerts`, `assistant`, `sql-workbench`, ..., offered when the server has the extension installed |
| `login`, `logout`, `config`, `cache`, `completion`, `doctor`, `ui` | sign-in, profiles and settings, the metadata cache, shell completion, the environment check, the terminal UI |
Add `--json` for machine-readable output. Errors print the server's `message`, `code` and `traceId`.
**Output** is a table on a terminal and JSON in a pipe; `-O json|table|text|csv|yaml` (or `QQQ_OUTPUT`)
chooses, and `-Q` reshapes it with JMESPath, as `aws --query` does. Values are the server's labels and
display values; DATE_TIME values are shown in your zone (`--utc` for raw instants). Text filters (`~`,
`STARTS_WITH`, `ENDS_WITH`, quick search) ask for case-insensitive matching; `--case-sensitive` asks for
an exact-case match.
Where things are kept:
- **Tokens** go in `~/.config/qqq-cli/credentials.json` (mode 0600), one entry per server. The CLI refreshes the access token at the provider before it expires, and once more on a 401.
- **`QQQ_TOKEN`** overrides the stored token with any bearer token, such as a QQQ access token exchanged for a JWT, or a session UUID.
- **`QQQ_SERVER`** sets the default server.
**Profiles and sign-ins.** Profiles name servers, as `aws --profile` does: `qqq --profile prod ...`,
`QQQ_PROFILE`, `qqq profile list|add|edit|use|remove` (each with a short name and a description; also
`qqq config profile …`), and Ctrl+O in `qqq ui` switches between them. Sign-ins live in the OS keyring (macOS Keychain,
Secret Service on Linux, DPAPI on Windows) or a 0600 file with `QQQ_KEYRING=file` for headless hosts.
OAuth2 tokens are refreshed before they expire; `qqq logout --provider-logout` also revokes them at the
provider. `QQQ_TOKEN` overrides everything with a bearer token, and `QQQ_PASSWORD` answers a
TABLE_BASED password prompt.
Every request carries `X-QQQ-Client: cli/qqq-cli <version>`, so the server's audits attribute it to the CLI.
**Exit codes:** 0 ok, 1 error, 2 usage or bad filter, 3 auth, 4 server unreachable or rate limited,
5 not found, 6 validation, 70 server error, 130 interrupted. Every request carries
`X-QQQ-Client: cli/qqq-cli <version>` for the server's audit trail.
## Writing a client
**Printing.** On a server with the printing extension (qbit-printing 2.x) and a print agent connected,
`qqq printer list` shows the printers (approved, exposed by their agent, formats), `qqq printer devices`
the agents and their health, and `qqq print person 1 2 --printer "Virtual PDF printer"` renders each
record as a record sheet, queues one job per record and follows them until they have printed (`--no-wait`
returns at once; `qqq print job status <id> --watch` follows later; `qqq print job cancel <id>` cancels a
job still queued). The printer given with `--printer` (or `qqq printer use`) is remembered per profile.
`qqq print list person -w "lastName = Odd" --order -id --columns id,firstName,lastName` prints a table list
of the whole result (up to 10,000 rows; `--page 2` for one page), and `some-command | qqq print text --title Log`
prints text. In `qqq ui`, **Ctrl+P prints what you see** on every screen: a record sheet, a table list of
the grid (all rows or this page), or the screen as text. Tables print only where the server enabled Print
for them (qbit-printing 2.1 `registerAll` enables every table). For local testing without hardware,
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to a directory.
[TRY-IT.md](TRY-IT.md) walks through every command against the sample server.
## The terminal UI in 60 seconds
`qqq ui` opens a classic full-screen terminal UI on the same profiles and sign-ins as the CLI:
```sh
qqq ui # the default profile; qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly, in the CLI grammar
qqq ui --check # check the terminal and tmux setup, offer fixes
qqq ui --theme light # high-contrast (default), classic or light
```
- **Home** is the landing screen: greeting and connection, counts, recently viewed records, quick actions,
application cards, your work (jobs, alerts) and the instance's home widgets. F10, Reset comes back here.
- **The function-key bar** at the bottom is always right for the current screen: F1 Help, F2 Search,
F3 View, F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit. When the terminal takes
the F-keys, Esc then a digit (or Ctrl+digit) stands for F1 to F10.
- **F2** is Search: records across tables (quick search), the main way into the data. **`/`** (or `.`, Ctrl+K)
is the find-anything palette: any app, table, process, widget, report, record or command, searched as you
type, fast on instances with thousands of tables. **Ctrl+O** switches servers, **Ctrl+P** prints what you see.
- **Ctrl+G** (or `:ask`) opens the Assistant panel: ask the server's assistant about the record or grid on screen
(the assistant extension with a model provider; Ctrl+D drops the context). `qqq assistant ask "<question>"` is the same from the CLI.
- **F9 is the command center**: every action of the current screen and of the application, searchable.
- **`:`** opens a command line that speaks the CLI grammar (`:table query person -w "id <= 3"`,
`:theme classic`, `:quit`), with history (Up, Down, Ctrl+R) and Tab completion. Commands that are not
screens run in-process and show their output.
- Esc goes back, and the screen below keeps its cursor, filter and page. PgUp and PgDn page everywhere;
Ctrl+F and Ctrl+B always work.
Grids page on the server, sort, filter with a guided builder or the grammar, quick search, show column
statistics and export; records open with their sections, child lists and next/previous; forms handle
contingent fields, uploads and validation; processes run as a wizard with progress and cancel. With the
printing extension, `p` (Print…) on a record or a grid selection opens a printer picker and the jobs are
followed in the status line (`print 1/3 RECEIVED`), with `:printjobs` for the recent ones. Keys, screens
and settings: [`packages/tui/README.md`](packages/tui/README.md).
## Compatibility
| | Supported |
|---|---|
| QQQ servers | UI API 1.6.0 up to, not including, 2.0 (QQQ 4.1.0-RC.15 or later). `qqq --version` prints the range; `qqq doctor` checks the server's version. |
| Extensions | Extension commands run when the server reports the extension in `GET /qqq/v1/version`; when it is missing, or older than the client needs, they exit 5 and say which. `qqq extensions` lists both sides. |
| Binaries | macOS arm64 and x64; Linux x64 and arm64, glibc and musl; Windows x64 (`qqq ui` needs Windows Terminal or another ConPTY terminal). The Windows and Intel macOS builds are cross-compiled and not yet exercised in CI ([docs/ci-lanes.md](docs/ci-lanes.md)). |
| npm packages | Node.js 20 or later. `@qrun-io/qqq-ui-api-client` needs only `fetch` and Web Crypto (no Node built-ins), so it runs on Bun and Deno too; `@qrun-io/qqq-core` and the CLI use Node built-ins (files, keyring). |
Versioning: `qqq` and `@qrun-io/qqq-core` follow semver from 1.0.0. `@qrun-io/qqq-ui-api-client`
follows the UI API version of the document it is generated from (1.8.0 here), and
`@qrun-io/qqq-ui-api-conformance` has its own version. All four are released together; see
[CHANGELOG.md](CHANGELOG.md).
## Packages
| Package | Version | What it is |
|---|---|---|
| [`@qrun-io/qqq-cli`](packages/cli) | 1.0.0 | `qqq`, the CLI, and its program as a library (`runCli`) |
| [`@qrun-io/qqq-core`](packages/core) | 1.0.0 | the client kernel: sessions and keyring, metadata cache, filter grammar, forms, processes, records, render model, output formats. No terminal code |
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | 1.8.0 | a typed TypeScript client generated from the merged UI API document (core plus 14 extensions), with OAuth2 device-flow and PKCE sign-in, token refresh and the realtime (SSE) stream |
| [`@qrun-io/qqq-ui-api-conformance`](packages/conformance) | 1.6.0 | a read-only conformance suite for any UI API server, and `qqq-dev-idp`, a local OIDC provider for testing native clients |
| [`packages/tui`](packages/tui) | in the binaries | `qqq ui`, the full-screen terminal UI (OpenTUI on Bun), bundled into every `qqq` binary; not an npm package |
All npm packages are published to GitHub Packages under `@qrun-io`.
A minimal client on the generated API, against the sample server:
```ts
import { createUiApiClient, unwrap, getNativeSignInConfig, deviceSignIn } from '@qrun-io/qqq-ui-api-client'
import { createUiApiClient, createSession, unwrap } from '@qrun-io/qqq-ui-api-client'
const config = await getNativeSignInConfig('https://admin.example.com')
const tokens = await deviceSignIn(config, (p) => console.log(`Open ${p.verificationUriComplete}`))
const api = createUiApiClient({ baseUrl: 'https://admin.example.com', token: () => tokens.accessToken, clientChannel: 'tui/my-tui 1.0' })
const baseUrl = 'http://localhost:8077'
const { sessionUUID } = await createSession(baseUrl) // MOCK; OAuth2 servers: deviceSignIn
const api = createUiApiClient({ baseUrl, sessionCookie: () => sessionUUID, clientChannel: 'tui/my-tui 1.0' })
const page = await unwrap(api.POST('/qqq/v1/table/{tableName}/query', {
params: { path: { tableName: 'person' } },
body: { filter: { criteria: [{ fieldName: 'lastName', operator: 'STARTS_WITH', values: ['K'] }], limit: 20 } },
body: { filter: { criteria: [{ fieldName: 'id', operator: 'LESS_THAN_OR_EQUALS', values: [3] }], limit: 20 } },
}))
console.log(page.records?.map((r) => r.values.firstName)) // [ 'Avery', 'Blair', 'Casey' ]
```
Paths, parameters, bodies and responses are typed from the document. Read the QQQ docs before you write a client:
- [`UI-API-CLIENTS.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CLIENTS.md) covers authentication, errors, widgets, processes, capabilities and validation.
- [`UI-API-CHANGELOG.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CHANGELOG.md) covers versioning.
`npm run mock` serves the merged document with [Prism](https://github.com/stoplightio/prism) on
http://127.0.0.1:4010, to build against without a server, and `qqq-ui-api-conformance --server <url>`
checks a server against the document.
## Mock server
## Documentation
`npm run mock` serves the merged document with [Prism](https://github.com/stoplightio/prism) on http://127.0.0.1:4010, so you can build a client without a server. Responses come from the document's examples and schemas.
| Document | What it covers |
|---|---|
| [TRY-IT.md](TRY-IT.md) | every CLI command and the TUI against the local sample server |
| [packages/cli/README.md](packages/cli/README.md) | the CLI package: install, configuration, environment, embedding `runCli` |
| [packages/tui/README.md](packages/tui/README.md) | `qqq ui`: keys, screens, the `:` line, settings, terminals that take keys |
| [packages/core/README.md](packages/core/README.md) | the client kernel's modules |
| [packages/ui-api-client/README.md](packages/ui-api-client/README.md) | the typed client: calls, sign-in, errors, realtime |
| [packages/conformance/README.md](packages/conformance/README.md) | the conformance suite and `qqq-dev-idp` |
| [docs/README.md](docs/README.md) | index of everything under `docs/` |
| [docs/extensions.md](docs/extensions.md) | the extension documents merged into the client |
| [docs/parity/feature-matrix.md](docs/parity/feature-matrix.md) | CLI and TUI status of every row of the web UI's acceptance matrix |
| [docs/server-findings.md](docs/server-findings.md) | UI API issues found while building the clients |
| [docs/release-runbook.md](docs/release-runbook.md) | how a release is cut, signed, published and packaged |
| [docs/packaging/README.md](docs/packaging/README.md) | the curl installer, Homebrew formula, Scoop manifest and platform notes |
| [docs/ci-lanes.md](docs/ci-lanes.md) | what CI runs: Linux verify and publish, macOS arm64 and Windows x64 lanes |
| [CHANGELOG.md](CHANGELOG.md) | every change by version |
## Conformance suite
```sh
qqq-ui-api-conformance --server https://admin.example.com # public checks only
qqq-ui-api-conformance --server https://admin.example.com --token "$T" # all read-only checks
```
The suite only reads. It never writes. It checks that:
- the version and the served document agree;
- every operation has a unique operationId and every `$ref` resolves;
- every extension the server lists has operations;
- requests without credentials or with forged tokens (`alg: none`, garbage) get a 401 with the error schema;
- metadata, table metadata (with `clientCapabilities`), query, count, record, jobs and realtime responses validate against the document;
- a 404 has the error schema, and `X-Request-Id` comes back as `traceId`.
## Testing a client locally
`qqq-dev-idp` is a small OIDC provider for **local development only**: device grant, refresh and RS256 JWTs, with one issuer per client, as Authentik does it. Run it with the QQQ sample app:
```sh
npx qqq-dev-idp --port 9400 --client sample-app --client sample-cli --user you@example.com --groups admins --auto-approve
# in a qqq checkout (QQQ 4.1.0-RC.15 or later)
export OAUTH2_BASE_URL=http://localhost:9400/application/o/sample-app/ OAUTH2_CLIENT_ID=sample-app OAUTH2_CLIENT_SECRET=dev \
OAUTH2_SCOPES="openid profile email offline_access" \
OAUTH2_NATIVE_CLIENT_ID=sample-cli OAUTH2_NATIVE_CLIENT_BASE_URL=http://localhost:9400/application/o/sample-cli/
./mvnw -f qqq-sample-project/pom.xml compile exec:java -Dexec.mainClass=com.kingsrook.sampleapp.SampleJavalinServer -Dqqq.sample.port=8077
qqq login --server http://localhost:8077
```
The UI API itself is documented in QQQ:
[`UI-API-CLIENTS.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CLIENTS.md) and
[`UI-API-CHANGELOG.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CHANGELOG.md).
## Development
Node 20+ (CI uses 22) and npm workspaces for the four packages; Bun 1.4+ for the TUI and the binaries.
```sh
npm ci
npm run spec:merge -- <core openapi.yaml> <extension yaml>... # or a server's /qqq/v1/openapi.json
npm run generate # regenerate packages/ui-api-client/src/schema.ts
npm run build && npm test
npm run build && npm test && npm run lint
npm run test:tui # the TUI's tests (Bun)
node packages/cli/dist/main.js --help # the CLI from the checkout; `... ui` runs the TUI from source with Bun
npm run generate # regenerate the client and the operations index from the document
npm run spec:merge-extensions # remerge the extension documents (sample server running)
npm run binaries # dist/qqq-<os>-<arch>[.exe] with the UI, and SHA256SUMS; --host, --musl, --cli-only
npm run release # every release check, then the tag and publish commands (prints only)
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # smoke a binary
```
Releases: push a tag `v<client version>`. The `publish` workflow publishes the three packages to GitHub Packages.
The CLI runs on Node 20 and later and uses no Bun-only API; Bun is needed only for the TUI and the
binaries. CI is the qqq-orb `cli` profile (`.qqq-ci.yml`); a tag `v<client version>` publishes the
packages to GitHub Packages ([`docs/ci-lanes.md`](docs/ci-lanes.md) says what it does and does not cover).
## License
## Contributing, security, license
Apache-2.0
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, checks, commit and pull request rules (signed commits on `main`).
- [SECURITY.md](SECURITY.md): report vulnerabilities privately, never in a public issue.
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
- Bugs and feature requests: [issues](https://github.com/QRun-IO/qqq-cli/issues).
Licensed under the [Apache License 2.0](LICENSE). QQQ itself is a separate project with its own license.
+44
View File
@@ -0,0 +1,44 @@
# Security policy
## Supported versions
| Version | Supported |
|---|---|
| 1.0.x (`qqq` 1.0, `@qrun-io/qqq-core` 1.0, `@qrun-io/qqq-ui-api-client` 1.8, `@qrun-io/qqq-ui-api-conformance` 1.6) | yes |
| 1.0.0 release candidates, 0.x | no; upgrade to the latest 1.0.x |
Fixes are released as a new patch version of the affected packages and binaries.
## Reporting a vulnerability
**Do not report security problems in public issues, pull requests or discussions.**
Email **security@qrun.io** with:
- what is affected (the CLI, `qqq ui`, a package, the installer or the packaging) and its version
(the first line of `qqq doctor`);
- how to reproduce it, and what an attacker gains;
- whether the problem is in this client or in the QQQ server (server problems are handled through
[QRun-IO/qqq](https://github.com/QRun-IO/qqq/security)).
When private vulnerability reporting is enabled on this repository, you can also use
[Report a vulnerability](https://github.com/QRun-IO/qqq-cli/security/advisories/new).
We acknowledge reports within three business days, keep you informed while we fix it, and credit you in
the advisory unless you ask us not to.
## Scope notes
Things in scope include: how sign-ins and tokens are stored (OS keyring, the `QQQ_KEYRING=file` store),
OAuth2 device and PKCE flows, handling of server-supplied file names and content, the curl installer and
its checksum verification, and the release binaries.
`qqq-dev-idp` (in `@qrun-io/qqq-ui-api-conformance`) approves sign-ins without a password by design. It
is for local development and tests only; running it where others can reach it is not a vulnerability in
this project.
## Dependencies
Dependabot watches the npm workspaces and the TUI's Bun lockfile ([.github/dependabot.yml](.github/dependabot.yml)).
Never commit secrets: use `QQQ_TOKEN` or the keyring, and keep tokens and private hostnames out of
issues and logs you share.
+449
View File
@@ -0,0 +1,449 @@
# Try qqq against the sample server
A hands-on walkthrough of qqq 1.0.0 (the CLI and `qqq ui`) from a source checkout, against a local QQQ
sample server. Each section says what exists and the command that runs it. To install a release instead,
see [Install](README.md#install) in the README; every command below works the same with an installed `qqq`.
## Prerequisites (once)
- Java 21 and Maven (for the sample server), Bun 1.4+ (`brew install bun`), Node 20+.
- A `qqq` checkout on `develop` at `../.worktrees/qqq-develop` (relative to this repo's parent), or set
`QQQ_DEV_WORKTREE`. Then `qqq-sample-server build` once (about two minutes), and after every pull of qqq.
- Launchers: `scripts/dev/sample-server.sh` and `scripts/dev/tui-spike.sh` are linked as
`~/.local/bin/qqq-sample-server` and `~/.local/bin/qqq-tui-spike`.
## Staying on the latest build
There is no install step. `qqq-tui-spike` runs the source checked out in this repo with Bun, so after
any commit the next launch is the latest; quit with F10 and start it again. The launcher prints the
branch and commit it is running (`spike/m0-opentui@6d68084`, with `+local` when there are uncommitted
changes) and the status line shows the same build id. I keep this checkout on the branch being built
and say when a new build is ready. After a reboot, `qqq-sample-server start` again; after pulling qqq
`develop`, `qqq-sample-server build` then `restart`.
## Try the release binary (`qqq-bin`)
`qqq-bin` on this machine (`~/.local/bin/qqq-bin`) is the darwin-arm64 release binary: the CLI
and the terminal UI in one file, no Node or Bun. It is the same build a user downloads; `qqq` stays the
from-source launcher. Both use the same profiles and keychain items.
```sh
qqq-bin --version # qqq 1.0.0 (darwin-arm64 binary with ui, <commit>, bun 1.4.4)
qqq-bin doctor # first line: the build, runtime and platform; then server, sign-in, terminal
qqq-bin --profile sample ui # the terminal UI from the binary
qqq-bin ui --check # the terminal check, with the build line support asks for
qqq-bin --profile sample table query person -l 3
scripts/verify-binary.sh ~/.local/bin/qqq-bin --server http://localhost:8077 # the whole smoke
```
A binary you download with a browser (from a GitHub release) is quarantined, and until the binaries are
notarized macOS refuses to open it ("Apple cannot check it for malicious software"). Clear the flag once:
`xattr -d com.apple.quarantine ./qqq-darwin-arm64`. `qqq-bin` was copied, not downloaded, so it has no flag;
Homebrew and `scripts/install.sh` do not set it either.
## Sample server (UI API 1.8.0, mock auth, in-memory data)
```sh
qqq-sample-server start # http://localhost:8077 (web UI at /, API at /qqq/v1)
qqq-sample-server status | log | restart | stop
```
Data resets on every restart. Mock auth means no sign-in; `POST /qqq/v1/manageSession` gives a session.
## TUI (`qqq ui`)
```sh
qqq ui # default profile; qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # straight to a screen
qqq ui --check # terminal and tmux check with fixes (also runs quickly at startup)
npm run test:tui # in-memory renderer tests; packages/tui/scripts/smoke.sh drives it in tmux
```
Keys (the function-key bar is the truth; F1 or `?` lists the keys of the screen; `/` finds anything, every action is in F9 and on `:`):
| Key | Does | Key | Does |
|---|---|---|---|
| F1 `?` | keyboard reference for the screen (keys only) | Esc | back; the grid keeps its row, filter and page |
| F2 | Search records (quick search): type, ↑↓, Enter opens, `o` grid, `f` one table, `h` recent, `?` syntax; `:search ada`; or just type on Home | Tab | Apps pane / content |
| `/` `.` Ctrl+K | find anything: apps, tables, processes, widgets, records, commands, help; `:find ada` | | |
| F3 Enter | view / open | PgUp PgDn, Ctrl+F Ctrl+B | page (Ctrl+F/B work even when tmux takes PgUp) |
| F4 `e` | edit | `]` `[` (PgDn PgUp in a record) | next / previous record of the list |
| F5 `r` | refresh | `s` | sort by the column under the cursor |
| F6 `f` | filter: guided builder or the grammar with completion | Space / `b` | select rows / run a process on them |
| F7 `n` | new (a child on a child list) | `x` `g` `v` | export, go to by key, view settings |
| F8 `d` | delete, asks | Ctrl+S | save a form or continue a process step |
| F9 | menu: areas, then each area's entries; places and records are found with `/` | `:` | command line, history and completion |
| Ctrl+O | servers: switch profile, add, edit | Ctrl+G `:ask` | Assistant panel: ask about the record or grid on screen (Ctrl+D drops the context; assistant extension with a model provider) |
| F10 | Quit, Reset to home, or Cancel (`:reset`) | Ctrl+C | quit now |
| `a` | actions: bulk insert/edit/delete, processes, saved views | `A` `D` `t` `Z` | audits, developer, tags, share (record) |
| `C` `P` `K` `T` `W` | comments, attachments, tasks, timeline, follow (record, collaboration extension) | `J` | raw JSON of an extension operation's output |
| `q` `S` `l` | quick search, column statistics, follow a link (grid) | Esc 1..0, Ctrl+1..0 | F1..F10 when the terminal takes them |
Things to try: `:table query person`, Enter on a row, PgDn through records, Esc (same row); F6, add a
condition, Tab to the grammar and type `AND (fi` then Tab; `:table create placeAddress` (country, state, city
narrow); Space on two people, `b`, Bulk edit (Space switches a field on, Ctrl+S, review, submit); on the person
grid `a` › Bulk insert from a file (a CSV with firstName,lastName,email: the mapping grid, review, result) and
Bulk delete to remove those rows again; `:app SampleWidgetsDashboard`, Enter on a widget heading for its
actions (export CSV, dropdowns); F9 Settings for themes
(per profile or for all), single-key shortcuts, download folder, PIN and the screen lock; `:theme light`,
`:theme classic`, `:theme high-contrast` (the default). Sign-in follows the server: MOCK and FULLY_ANONYMOUS
get a session, TABLE_BASED asks for a username and password, OAUTH2 and AUTH_0 sign in in the browser (OAUTH2
can use a device code instead). See `packages/tui/README.md` for every key, the settings file and the keys
your terminal or tmux may take (`qqq ui --check` finds them).
The M0 spike (`qqq-tui-spike`, `packages/tui-spike`) stays in the repo for reference.
## CLI (`qqq`)
`qqq` on your PATH runs the CLI from this checkout with Bun (no build step; the client and kernel packages
are rebuilt when their source changed). Grammar is `qqq <noun> <verb>`; the 0.1 flat commands (`apps`,
`tables`, `query`, `get`, `run`, `jobs`, `tail`) still work as aliases.
```sh
qqq login --server http://localhost:8077 # MOCK: server session (cookie). OAUTH2/AUTH_0: device flow.
qqq whoami
qqq apps
qqq table list --app peopleApp
qqq table describe person
qqq table query person -w "lastName STARTS_WITH S" --order -id -l 5
qqq table query person -g "(firstName = Avery OR firstName = Blair) AND birthDate > now(-40y)"
qqq table query person -w "annualSalary >= 300000" --count
qqq table query person -w "createDate > thisPeriod(month)" --print-filter # the QueryFilter JSON
qqq table query person -l 2 --output csv --fields id,firstName,email
qqq table query person --output yaml -Q '[].{id: values.id, name: recordLabel}' # JMESPath, as aws --query
qqq table get pet 3
qqq table create pet --generate-input-skeleton
qqq table create pet --set name=Pixel --set personId=2 --set speciesId=2
qqq table update pet 7 --set name=Pixel2
qqq table delete pet 7 # asks; --yes skips
qqq table export person --format xlsx --out people.xlsx
qqq table export person -w "id <= 5" --order -id --fields id,firstName,email --format tsv -o - # -o/--out: stdout; format also from --out
qqq table query person --search lair # quick search: CONTAINS over the text fields, ANDed with -w/-g
qqq table get country --key name=Canada # Go To by a unique key (exit 5 none, exit 2 several)
qqq table copy pet 1 --set name=Copycat # copy a record (--dry-run shows the values first)
qqq table developer person 1 # developer view: associated scripts and panels (record security, ESB)
qqq table apis person ; qqq table api-doc <t> <api> <version> [--out f] # application APIs (none in the sample)
qqq script list|show|store|logs <t> <pk> … # associated scripts: show [--revision n], store --file f | --editor -m msg, logs <field> <revisionId>
qqq apps show greetingsApp # an app's home: actions, reports, tables with record counts, widgets
qqq report list # the sample app has no reports
qqq help show table person ; qqq help show table person firstName ; qqq help show process greet # help content from the metadata (none in the sample)
qqq table query person --join pet --select -l 4 # joins: exposed joins (none in the sample), else the table's associations
qqq table query person --fields id,firstName,pet.name -w "pet.name STARTS_WITH C" --order pet.name
qqq table count person --join pet # many-side join: count and distinctCount
qqq table get person 1 --join pet
qqq table variants person # none in the sample; --variant <id|name|type:id> on table commands and process run
qqq table stats person annualSalary --local -l 3 # column statistics; the sample has no columnStats process, --local computes them
qqq saved-view list person ; qqq saved-view open person <view> ; qqq table query person --view <view> -w … # saved views (none in the sample)
qqq saved-view save person --name Mine -w "lastName = Sample" --order -id --fields id,firstName -l 50 # -l is the view's page size
qqq saved-view rename person Mine Mine2 ; qqq saved-view delete person Mine2 --yes
qqq audit list person 1 # audit history (the sample audits nothing: exit 2 with the reason)
qqq process list ; qqq process describe sleepInteractive
qqq process run sleepInteractive -v sleepMillis=1500 # async job, polled, progress on stderr
qqq process run clonePeople -r 1,2 # records by primary key
qqq api --list ; qqq api getTableMetaData --path tableName=person -Q 'fields.*.label'
qqq widget list ; qqq widget get SampleLineChartWidget # bars in the terminal; --csv for the numbers
qqq widget get SampleStackedBarChartWidget --output json # the raw payload
qqq prefs list ; qqq prefs get regional ; qqq prefs set regional --set weekStartsOn=MONDAY
qqq help-search person ; qqq report run <name> --format xlsx
qqq search Avery ; qqq tail
qqq extensions ; qqq esb table person # extensions (qbits): see "Extensions (qbits)" below
```
Profiles name servers, as `aws --profile`; sign-ins live in the OS keyring (macOS Keychain here, Secret Service on
Linux, DPAPI on Windows; `QQQ_KEYRING=file` for a 0600 file). The first sign-in becomes the default profile.
```sh
qqq login --server http://localhost:8077 --profile sample # creates the profile; the Keychain gets an item "qqq"
qqq profile list --probe ; qqq profile use sample ; qqq config show
qqq profile add enterprise http://localhost:8078 --short ent --description "22 apps" --check
qqq profile edit enterprise --auth-mode device # in qqq ui: Ctrl+O lists them, Enter switches
qqq --profile sample table count person ; QQQ_PROFILE=sample qqq whoami
```
OAuth2 sign-in (an OAUTH2 server with a native client): the device flow is the default, `--browser` signs in with the
authorization code and PKCE on a loopback redirect (`http://127.0.0.1:{port}/callback`; `--port`, `--redirect-host
localhost` and `--no-open` when the provider needs an exact registered URI or there is no browser). Tokens are
refreshed at the provider before they expire; a refresh the provider refuses (revoked, expired) exits 3 with "sign in
again". `qqq logout --provider-logout` also revokes the refresh token (RFC 7009) and opens the provider's end-session
URL. Try it with a second sample instance and the dev provider (leave the 8077 instance alone):
```sh
node packages/conformance/dist/dev-idp.js --port 9400 --client sample-app --client sample-cli --user you@example.com --groups admins
# in ../.worktrees/qqq-develop/qqq-sample-project:
OAUTH2_BASE_URL=http://localhost:9400/application/o/sample-app/ OAUTH2_CLIENT_ID=sample-app OAUTH2_CLIENT_SECRET=dev \
OAUTH2_SCOPES="openid profile email offline_access" OAUTH2_NATIVE_CLIENT_ID=sample-cli \
OAUTH2_NATIVE_CLIENT_BASE_URL=http://localhost:9400/application/o/sample-cli/ \
mvn -q -DskipTests -Dcheckstyle.skip=true compile exec:java -Dexec.mainClass=com.kingsrook.sampleapp.SampleJavalinServer \
-Dqqq.sample.port=8078 -Dqqq.sample.esb.port=61617
qqq login --server http://localhost:8078 --profile oauth # device flow: open the printed URL, it approves
qqq login --server http://localhost:8078 --profile oauth --browser # browser: Approve or Deny on the provider page
qqq -p oauth whoami ; qqq -p oauth process run sleepInteractive -v sleepMillis=500
qqq -p oauth logout --provider-logout # revoke, then the end-session page
```
A denied request, a redirect whose `state` does not match, and a code the provider will not exchange each exit 3 and
save nothing; the new token is also checked against the server before the sign-in is kept.
Shell completion (nouns, verbs, options, and table, process, widget, field and operation names from the local
metadata cache):
```sh
eval "$(qqq completion zsh)" # or bash; fish: qqq completion fish | source; pwsh: see qqq completion pwsh
qqq cache show ; qqq cache refresh ; qqq cache clear # the metadata cache behind listings and completion (5 minutes, ~/.cache/qqq)
```
Global options go anywhere: `--server`, `--profile` (`-p`), `--output json|table|text|csv|yaml` (`-O`), `--json`,
`--query` (`-Q`), `--fields`, `--yes` (`-y`), `--utc`, `--trace`, `--version`. Table on a terminal, JSON in a pipe,
`QQQ_OUTPUT` overrides. `--server` and `--path` have no short form (`-s` is `--set`, `-p` is `--profile`), and `-V`
is `--view` on `table query` (the version is `--version`). The same option names mean the same thing on every noun:
`--where/-w`, `--group/-g`, `--order` (sort; no short form), `--out/-o` (the output file, on every command that
writes one), `--limit/-l`, `--skip`, `--fields/-f`, `--join/-j`, `--variant`, `--reveal`, `--yes`; `-o` as the sort
still works on `table query`, `saved-view save` and `saved-report preview/set` until 1.1, with a deprecation note,
and a sort field that looks like a file path (`out.csv`) is warned about; numbers are checked before anything is sent (`-l many` exits 2). Every list ends with the same
footer, `(3 records; joined: pet; more may exist: --limit, --skip or --all)`. Exit codes: 0 ok (also "Not deleted."
and "Not submitted." after a no), 1 error, 2 usage or bad filter (also Ctrl-D at a question), 3 auth, 4 server
unreachable, rate limited or a 502/503/504, 5 not found, 6 validation, 70 server error, 130 Ctrl-C (a run cancelled,
`tail` stopped). `qqq job …` says this sample server has no jobs operations.
Downloads (`table download`, `table export`, `report run`, `widget get --csv`, process files) go where `--out` or
`--out-dir` says (`-` is stdout), else into the download directory when one is set, else the current directory;
a default name never replaces a file (`name-1.ext`). The settings live in the `ui` section of
`~/.config/qqq/config.json` and are the same ones the TUI's F9 Settings change:
```sh
qqq config set download-dir ~/Downloads/qqq # created when missing; an empty value removes it
qqq config get ; qqq config get download-dir # also upload-dir, download-mode (ask|silent, the TUI) and theme
qqq config set theme classic ; qqq -p sample config set theme light # globally, or for one profile
qqq table export person # wrote ~/Downloads/qqq/person_2026-11-01_102316.csv (local date and time)
qqq history --limit 5 ; qqq history --since 2h --grep person # what you did in the CLI and the TUI (qqq ui: :history, Ctrl+R on the : line)
```
Is everything in order? `qqq doctor` checks the profile, the server (reachable, UI API version within 1.6.0 to
1.8.0, authentication type, the sign-in accepted, extensions), the keyring and the metadata cache, and exits 1 with
what failed. `--conformance` adds the read-only conformance checks. The terminal rows come from the same check as
`qqq ui --check` (tmux bindings that take PageUp/PageDown and F-keys, alternate screen, colour, unicode, size);
`--fix` offers each live tmux fix (`--yes` answers those) and appends them to `~/.tmux.conf` only after a y typed at
the terminal.
```sh
qqq doctor ; qqq doctor --conformance ; qqq doctor --fix
```
Contingent fields (field 1 narrows field 2) are handled by the kernel's form session, for tables and process
steps alike. The sample's Address book is country → state → city:
```sh
qqq table create placeAddress --set label=Home --set countryId=USA --set stateId=Missouri --set cityId="St. Louis"
qqq table create placeAddress --set label=X --set stateId=Missouri # exit 2: set Country first
qqq table create placeAddress -i # prompts; choices narrow after each answer
qqq table update placeAddress 3 --set cityId="Kansas City" # narrowed by the record's own state (Missouri), no -i needed
qqq table update placeAddress 3 --set stateId=Illinois # clears and sends the city that depended on it
qqq table update placeAddress 3 -i # prompts with the current values
qqq process run placeAddressPick # asks for what the step lacks, same narrowing
qqq process run placeAddressPick -v countryId=USA -v stateId=Illinois -v cityId=spring # labels, ids or unique partials
qqq table create pet --set name=Rex --set personId=1 --set speciesId=1 --set birthDate=2020-13-40 # exit 6, checked locally
```
Processes show every screen as the web does, in component order: help text, HTML as text, `label: value` pairs
(on every step, not only the last), the process records as a table (paged through `GET …/records`, `--record-limit`),
the validation review (count, preview, then the validation lines), the summary lines, files to download (saved to
`--out-dir` with the path printed) and widgets in steps. Intermediate screens go to stderr; the last screen is the
command's output (`--output json` gives the raw response).
```sh
qqq process run greetInteractive -r 1,2,3 -v greetingPrefix=Hi # results: values, then the greeted records
qqq process run clonePeople -r 1,2 # on a terminal: "Validate fully … or skip validation? [v/S]"
qqq process run clonePeople -r 1,2 --validate # validation lines, then "Submit? [Y/n]" (n cancels the run)
qqq process run sleepInteractive -v sleepMillis=8000 # press Ctrl-C while it runs: POST …/cancel, exit 130
qqq process describe person.bulkEdit # steps, fields, and the process and step help
```
Ctrl-C during a run (while a job runs or at a prompt) asks the server to cancel it (`POST …/cancel`) and exits 130;
a second Ctrl-C leaves at once. When a step reports a `backStep`, the terminal offers `b` to go back to it with the
values entered so far (none of the sample's processes declares one). A process's `minInputRecords` and
`maxInputRecords` are checked before `init` (a filter is counted first).
Possible-value fields take an id, an exact label, or a partial label that matches one choice; an ambiguous partial
lists the candidates. Validation (required, type, length, range, pattern) runs locally before the request, with the
same codes and messages the server would give. Form adjusters (on-load and on-change rules an application declares)
are called when the server has them; this sample app has none.
Related records and the record view. `table get` shows sections in metadata order (hidden sections and fields never),
empty values as `--`, and adornments as text: a record link names its table and key (`Avery Sample → person 1`),
a chip is `[value]`, a REVEAL value is masked until `--reveal`, an error value starts with `!`, a tooltip follows in
parentheses, HTML is shown as text, a stored file shows its size or name and the download command.
```sh
qqq table get person 1 --associations # every association as tables, all levels: Pet [pets], then each pet's notes
qqq table get pet 1 --links # the records this one links to, with the command that opens each
qqq table get pet 1 --follow personId # open the related record (LINK adornment or possible-value table)
qqq table children person 1 # the record's associations and how many children each has
qqq table children person 1 pets --limit 2 --skip 2 # one page of a child list, with the count and the next --skip
qqq table children pet 1 notes --all
qqq table create pet --parent person=2 --set name=Rexley --set speciesId=Dog # a child linked to its parent
qqq table create petNote --parent pet=5 --set "note=Vet visit" # then: qqq table get person 2 --associations
echo hello > notes.txt ; echo other > other.txt
qqq table create fieldLab --set name=withfile --upload blobValue=./notes.txt # multipart/form-data; note the id it prints
qqq table update fieldLab 1 --upload blobValue=./other.txt ; qqq table update fieldLab 1 --set blobValue= # replace, remove
qqq table download fieldLab 1 blobValue -o copy.txt # -o - for stdout; without -o, the download directory
qqq table get person 1 --help-content # table, section and field help inline (this sample app declares none; qqq help show for help alone)
```
DATE_TIME values are typed and shown in your zone (the `TZ` environment) and sent as UTC instants, as the web
UI does; `--utc` shows the raw instants. A time skipped by a spring-forward change moves forward by the gap; a
time repeated by a fall-back change means its first occurrence. In filters, a date alone on a DATE_TIME field
means local midnight. Fields the server formats in a declared zone (fixed or per-record) keep its text.
```sh
TZ=America/Chicago qqq table create fieldLab --set name=tz --set dateTimeValue=2026-10-31T14:30 # stored 19:30Z; use the id it prints below
qqq table get fieldLab 4 # Date Time Value 2026-10-31 14:30:00 CDT
qqq --utc table get fieldLab 4 # Date Time Value 2026-10-31T19:30:00Z
TZ=Asia/Tokyo qqq table get fieldLab 4 # 2026-11-01 04:30:00 GMT+9
qqq table query fieldLab -w "dateTimeValue BETWEEN 2026-10-31,2026-10-31T23:59" --print-filter
qqq process run fieldLab.bulkEdit -r 4 -v bulkEditEnabledFields=dateTimeValue -v dateTimeValue=2026-10-31T09:15 -v name=tz --validate
```
Filter values can be expressions: `now()`, `now(-7d)`, `thisPeriod(month)`, `lastPeriod(week)`,
`startOf(quarter,-1)`, `me()`, `${variable.x}`; quote values with commas: `city IN "St. Louis, MO",Chicago`.
Bulk load and bulk changes run the table's bulk processes with every screen the web shows: upload, column
mapping, value mapping, the review (record count, preview, full validation) and the result. Loading or changing asks
first unless `--yes`; without a terminal and without `--yes` the run is cancelled and the command exits 2. The
mapping comes from the server's suggestion, a mapping document (`--mapping m.yaml`), `--column field=column` and
`--default field=value`, or `-i` (questions for the header row, layout, each field's column, fields to add, values to
map; `b` goes back). Use your own file, or save a template first.
```sh
qqq table actions person # what you can do: bulk load, bulk edit/delete, processes
qqq table bulk-insert person --template flat # saves "Person - Flat.csv" (the upload instructions' template)
printf 'First Name,Last Name,Email,Employed\nTry,Bulk,try@example.com,yes\n' > try.csv
qqq table bulk-insert person --file try.csv --column isEmployed=Employed --dry-run # validates, shows, changes nothing
qqq table bulk-insert person --file try.csv --column isEmployed=Employed --save-mapping try.yaml --yes
qqq table bulk-insert person --file try.csv -i # edit the mapping on the terminal; asks before loading
qqq table bulk-edit person -w "lastName = Bulk" --set daysWorked=5 --clear birthDate --yes # only these fields change
printf 'Id,Last Name\n<id from the insert>,Bulk2\n' > edit.csv
qqq table bulk-edit person --file edit.csv --key id --yes # rows find their records by the key field
qqq table bulk-delete person -w "lastName STARTS_WITH Bulk" # review and validation, then "Delete the records? [y/N]"
qqq bulk-profile list person ; qqq table bulk-insert person --file try.csv --saved <name> # saved profiles (none in the sample)
qqq bulk-profile save person -n Mine --mapping try.yaml --file try.csv ; qqq bulk-profile rename person Mine Mine2 ; qqq bulk-profile delete person Mine2 --yes
```
A mapping document names fields (name, `child.field`, or the label) and where their values come from:
```yaml
layout: TALL # FLAT (default), TALL or WIDE; tables with child tables only
hasHeaderRow: true
fields:
firstName: First Name # a column by header name (or a letter: B)
lastName: { column: B }
isEmployed: { column: Employed, values: { Y: yes, N: no } } # value mapping
pets.name: Pet Name
pets.speciesId: { column: Species, values: { Dog: Dog, Cat: Cat } } # labels resolve to ids
annualSalary: { default: 50000 }
keyFields: id # bulk edit with a file
```
Records, queries and developer tools added for parity:
```sh
qqq table columns person id lastName email # the columns table query shows, kept per table (shared with the TUI); --reset
qqq table query pet -w "speciesId = Cat" # possible-value labels in filters are sent as ids
qqq table query person -w "weekday(birthDate) IN 1,7" --print-filter # ISO weekdays; the sample's H2 fails it (finding 24)
qqq table update fieldLab 3 -e htmlValue # edit an HTML or code field in $VISUAL/$EDITOR; HTML shows as text
qqq table get person 1 --copy email # copy a value (REVEAL values unmasked) to the clipboard
qqq table query <variant table> --variant - # the variant chosen for a table is remembered; - forgets it
qqq table api-call <t> <api> <version> GET <path> # try a request against an application API (none in the sample)
qqq script test <t> <pk> <field> -v input=value # run a script against test inputs (no scripts in the sample)
qqq table widget-form <t> <pk> <widget> --set f=v # edit a dynamicForm widget on a record form (none in the sample)
```
Dashboards, navigation, reports, sharing and sign-in added for parity:
```sh
qqq home # counts, quick actions, one card per app
qqq find person # the palette: apps, tables, processes, reports by label, with the command
qqq apps show SampleWidgetsDashboard --widgets # every widget once, in declared order, sized in twelfths
qqq widget get SampleMultiStatisticsWidget # groups with icon, header and values
qqq widget get <widget> --record <table>=<id> # widgets on a record (cronUI, pivot and filter setups read it)
qqq widget get <process widget> --run # run a process widget's process here
qqq report run <report> --format xlsx -v name=value --out-dir . # through the report's process; no process: the report route (--out)
qqq saved-report list ; qqq saved-report show <r> ; qqq saved-report preview <r> -l 10
qqq saved-report set <r> -w "state = ${variable.state}" --order -total --columns id,total
qqq saved-report pivot <r> --rows state --values SUM:total ; qqq saved-report pivot <r> --clear
qqq saved-report run <r> --format csv ; qqq saved-report schedule <r> --cron "0 0 7 ? * MON-FRI" --time-zone America/Chicago --to me@example.com
qqq share list savedReport <id> ; qqq share add savedReport <id> user:<id> --scope read-only ; qqq share scope savedReport <id> <share> read-write
qqq login --username <name> # TABLE_BASED servers (password asked, or QQQ_PASSWORD); the sample uses MOCK
qqq table query person -w "lastName ~ sample" # text matches send CASE_INSENSITIVE (the server drops it until qqq#1358); ~= matches case
```
Where M1 stands: `docs/parity/feature-matrix.md` has the CLI status of every Next UI acceptance row. Not yet verifiable here: jobs on a durable-jobs server, TABLE_BASED and AUTH_0 sign-in (stub-tested). Built but not verifiable against this sample, which has none of them:
saved views, server-side column statistics (columnStats), audits, a variant-backed table, exposed joins (joins were
checked through an association join instead); node tests and a local stub server cover those.
## Extensions (qbits)
An extension is a qbit or module that adds UI API operations. The CLI knows every extension whose
document was merged into the client (13 today, listed in `docs/extensions.md`); a server installs
some of them and lists them in `GET /version`. Each has a noun:
| Noun | Extension | First-class verbs | Generated verbs (one per operation) |
|---|---|---|---|
| `qqq esb` | esb (qqq) | `overview`, `table <t>`, `process <p>`, `dead-letters <trigger>`, `messages <destination> [--trigger t]` | `get-overview`, `get-table`, ... |
| `qqq collab` | collaboration | `presence <table> [pk] [--watch\|--observe]`, `comments list\|add`, `favorites list\|add\|remove`, `recents`, `follow [--status]`, `unfollow`, `my-work`, `people [q]` | `get-records-timeline <table> <pk>`, `get-records-tags`, `put-records-assignment`, ... (45) |
| `qqq assistant` | assistant (qbit-assistant) | `ask "<question>" [--table t [--record id]] [-c id] [--json]`, `config` | `get-config`, `get-conversations`, ... |
| `qqq health`, `alerts`, `data-analysis`, `db-performance`, `edi`, `qa-capture`, `quick-search`, `release-notes`, `secured-data`, `sql-workbench` | the qbit of that name | none yet | every operation, e.g. `qqq sql-workbench get-capabilities` |
Generated verbs are the method plus the literal path segments after the extension's prefix. They take
path parameters as arguments or `--path name=value`, plus `--param`, `--body-json`, `--form` and `--out`.
`qqq <noun> --help` lists them with the operation behind each.
What happens:
- **Installed.** The command runs. `qqq whoami` and `qqq extensions` show it with its version.
- **Not installed.** Every verb of the noun, and `qqq api <operation>` for any of its operations, stops
before calling the server, with exit 5: `extension collaboration is not installed on this server
(installed: esb 1.1.0)`.
- **Installed below the minimum the client needs** (esb, health and collaboration need 1.1.0): exit 5,
`this client needs extension esb 1.1.0 or later; this server has 1.0.0`.
- **Installed but unknown to this client** (a newer qbit): listed by `qqq whoami` and `qqq extensions`
as `unknown to this client`, never dropped. It has no noun until the client document is regenerated.
The installed list comes from `GET /version`, cached with the metadata for five minutes (`qqq cache
refresh` rereads it). `QQQ_EXTENSION_CHECK=off` skips the check, for a Prism mock whose `/version` is
only an example.
```sh
qqq extensions # known vs installed, versions, minimums, operations
qqq api --list --extension collaboration # an extension's operations (the list has an extension column)
qqq esb table person # sample server: what person publishes, which processes it triggers
qqq esb process syncPerson ; qqq esb messages personEvents --trigger syncPerson.personEvents
qqq collab presence person 1 # sample server: exit 5, collaboration is not installed
```
Collaboration needs a server with qbit-collaboration. Its repo has a demo server (memory backend, MOCK
users alice, bob and admin); it runs on port 8098 beside the sample server:
```sh
cd ../qbit-collaboration && ./mvnw -q test-compile dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/demo-classpath.txt
mkdir -p /tmp/fe/next-dashboard && echo '<html></html>' > /tmp/fe/next-dashboard/index.html # it insists on a Next export
java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -cp "target/test-classes:target/classes:$(cat target/demo-classpath.txt):/tmp/fe" \
com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
qqq login --server http://localhost:8098 --profile collab # alice
qqq --profile collab collab presence person 1 --watch # join; prints each change until Ctrl+C
qqq --profile collab collab presence person --observe # watch the table without appearing
qqq --profile collab collab comments list person 1 ; qqq --profile collab collab comments add person 1 "Looks right"
qqq --profile collab collab favorites list ; qqq --profile collab collab favorites add person 4
qqq --profile collab collab follow person 2 --comments off ; qqq --profile collab collab my-work
```
To see someone else, open `http://localhost:8098/?createMockSession=true&user=bob` in a browser on a
person (needs a real Next export), or hold a lease as bob with curl (`POST /qqq/v1/qbit/collaboration/presence?user=bob`).
Presence lists everyone holding a lease on the record or table, you included while you `--watch`;
`since` is when the CLI first saw them.
## Reporting what you find
Say which screen, what you pressed, what you expected. If the TUI crashes, the terminal should be restored;
if it is not, `reset` fixes it and the stack trace is in the launching terminal.
+48
View File
@@ -0,0 +1,48 @@
# qqq-cli documentation
Start with the [repository README](../README.md) (install, a tour of the CLI and the terminal UI,
compatibility) and [TRY-IT.md](../TRY-IT.md) (every command against the local QQQ sample server). Each
package has its own README: [cli](../packages/cli/README.md), [tui](../packages/tui/README.md),
[core](../packages/core/README.md), [ui-api-client](../packages/ui-api-client/README.md),
[conformance](../packages/conformance/README.md).
Everything under `docs/`:
## Using and integrating
| File | What it is |
|---|---|
| [extensions.md](extensions.md) | how the extension (QBit) OpenAPI documents are merged into the client document, which ones are in it, and how to rebuild it |
| [server-findings.md](server-findings.md) | UI API problems found in the QQQ server while building the clients: how to reproduce each, the client workaround, and the filed issue |
## Releasing and packaging
| File | What it is |
|---|---|
| [release-runbook.md](release-runbook.md) | from a green branch to a published release: checklist of what needs the maintainer, versions, release checks and binaries, signing, tags and npm, the GitHub release, Homebrew and Scoop, smoke tests, recovery |
| [release-notes/1.0.0.md](release-notes/1.0.0.md) | the GitHub release body for `cli-v1.0.0` (the release commands are in an HTML comment at the top) |
| [release-notes/1.0.0-rc.1.md](release-notes/1.0.0-rc.1.md) | the GitHub release body for the release candidate `cli-v1.0.0-rc.1`, kept for history |
| [packaging/README.md](packaging/README.md) | the install paths (curl installer, Homebrew, Scoop, npm), where the formula and manifest live, platform notes (Gatekeeper, SmartScreen, musl), and how the terminal UI is bundled |
| [packaging/homebrew-tap/](packaging/homebrew-tap/README.md) | the complete `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb`, README, LICENSE and its qqq-orb CI files |
| [packaging/qqq.json](packaging/qqq.json) | the Scoop manifest, copied to `bucket/qqq.json` in `QRun-IO/scoop-bucket` |
| [ci-lanes.md](ci-lanes.md) | what CircleCI (the qqq-orb `cli` profile) runs: the Linux verify-and-publish job and the macOS arm64 and Windows x64 lanes, what is still outside CI, and the history of the orb changes |
`scripts/update-packaging.mjs` fills the version and checksums of the formula and manifest from a
release's `SHA256SUMS`.
## Parity with the web UI
| File | What it is |
|---|---|
| [parity/feature-matrix.md](parity/feature-matrix.md) | the CLI and TUI status (pass, n/a, blocked) of every row of the web UI's acceptance matrix; generated by `scripts/parity-matrix.py` and checked by the release gate |
| [parity/next-feature-matrix.md](parity/next-feature-matrix.md) | the input to that script: a copy of the Next UI acceptance matrix from `qqq-frontend-next` |
| [parity/CLI-PLAN.md](parity/CLI-PLAN.md) | the plan that took the CLI column to 1.0 parity, kept for history |
## Reviews
| File | What it is |
|---|---|
| [review-2026-10-31.md](review-2026-10-31.md) | the hardening review of the CLI and the kernel before 1.0: each finding, how it was reproduced, and its fix and test |
Elsewhere in the repository: [CHANGELOG.md](../CHANGELOG.md), [CONTRIBUTING.md](../CONTRIBUTING.md),
[SECURITY.md](../SECURITY.md), and `packages/core/src/extensions/README.md` (the extension registry).
+234
View File
@@ -0,0 +1,234 @@
# CI lanes for the qqq CLI
## What runs today
`.circleci/config.yml` is the qqq-orb setup stub on the `0.8` track (it picks up every 0.8.x release by
itself); `.qqq-ci.yml` selects the orb's `cli` profile and, since qqq-orb **0.8.33**, turns on the
platform lanes with `platforms: [macos-arm64, windows-x64]`. The profile generates three jobs:
| Job | Executor | What it runs |
|---|---|---|
| `cli-macos-arm64` (`qqq-orb/cli_platform`) | macOS, Xcode 16.4, `m4pro.medium` | Node 20.20.2 (the `engines` floor) with nvm, `npm ci`, `npm run build && npm test && npm run lint` |
| `cli-windows-x64` (`qqq-orb/cli_platform`) | Windows Server 2022, `windows.medium`, steps in Git Bash | the same, on Windows (keyring DPAPI path, Windows paths) |
| `qqq-orb/cli_verify_publish` | Linux, `cimg/node:22.23` | `npm ci`; `npm run generate` then `git diff --exit-code` on `schema.ts` and `operations.ts`; build, test, lint; a Prism smoke (`GET /qqq/v1/version`, `/metaData/authentication`); on a tag `v<client version>` with `publish.release: true`, publishing (below) |
On branches and pull requests the three jobs run side by side. On a release tag `cli_verify_publish`
**requires both platform lanes**, so nothing is published unless macOS and Windows are green. Publishing
puts all four packages on GitHub Packages (client, core, conformance, CLI, in that order) with the dist-tag
`latest` when all four versions are final and `rc` when any of them has a prerelease part. The CircleCI
plan must include the `m4pro.medium` and `windows.medium` resource classes.
The orb's generator accepts this manifest: rendered with `src/scripts/generate_pipeline.py` from qqq-orb
v0.8.33, a push to `main` gives `cli-macos-arm64`, `cli-windows-x64` and `cli_verify_publish` in
`test_only`, and the tag `v1.8.0` gives the same in `publish_release` with `cli_verify_publish` requiring
both lanes and bound to the `qqq-maven-registry-credentials` context.
Still not in CI: the TUI (`npm run test:tui`, `typecheck:tui`, which need Bun), and building and smoking
the platform binaries (`scripts/build-binaries.mjs --host`, `scripts/verify-binary.sh` / `.ps1`); the
lanes run the Node packages only. Those stay release-machine steps in `docs/release-runbook.md`.
### History
Before orb 0.8.33 the profile generated only `cli_verify_publish` (on `cimg/node:22.12`), without lint,
without the `operations.ts` drift check, without publishing `@qrun-io/qqq-core`, always with the
dist-tag `rc`, and with no macOS or Windows lane. The gap list and proposal below are what led to
0.8.33; they are kept for the record.
Checked on 2026-10-31 by running the orb's own `cli_verify.sh` in Docker on the release branch, plus
`npm run prepublishOnly` and `npm run lint`:
| Image | Result |
|---|---|
| `node:20-bookworm-slim` (Node 20.20.2) | all green |
| `cimg/node:22.12` (the orb's image) | all green since 2026-10-31. Before, two prompt tests (Ctrl-D and Ctrl-C at a question) hung: on 22.12 readline leaves a pending question unsettled when Ctrl-D closes the interface. `prompt.ts` now also settles the question when the interface closes; checked on Node 20.20, 22.12 and 24.22. |
Gaps in the profile as of 0.8.32 (all closed in 0.8.33 except the TUI, which still needs Bun):
- **`@qrun-io/qqq-core` is not published.** `@qrun-io/qqq-cli` depends on it, so the CLI package the
orb publishes cannot be installed until core is published by hand (`scripts/release.mjs` prints the
command). Add it to `cli_publish.sh`, before the CLI, with the same destination check.
- **No lint.** Commit 7f1ad5e added `prepublishOnly` and `lint` for the orb's node profile, but this repo
uses the `cli` profile, which runs neither. `npm run lint` now also checks that the release versions
agree; it should gate a publish.
- **One Node version.** The packages declare `engines.node >=20`; the orb tests only 22.12. Until this
release, `npm test` failed on Node 20 (the quoted test glob needs Node 21+), and nothing noticed.
- **No macOS or Windows.** The keyring has three platform paths (Keychain, Secret Service, DPAPI through
PowerShell), the binaries are built for five targets, and only Linux runs anything.
- **`operations.ts` drift** is regenerated by `npm run generate` but not diffed; add it to the
`git diff --exit-code` line (`scripts/release.mjs` already checks both).
- The TUI (`npm run test:tui`, `typecheck:tui`) needs Bun, which the image does not have.
## Proposal for James: two orb changes
Written before 1.0.0-rc.1 and kept as sent. Part 1b became qqq-orb PR #165; all of it, and the
platform lanes of Option A below, shipped in qqq-orb 0.8.33, and this repository has opted in.
> **Subject: qqq-orb `cli` profile: publish @qrun-io/qqq-core, and opt-in macOS/Windows lanes**
>
> qqq-cli 1.0.0-rc.1 is ready to tag except for two things only the orb can fix.
>
> **1. Publish `@qrun-io/qqq-core` (blocking).** The CLI now depends on the kernel package
> `@qrun-io/qqq-core`, but `cli_publish.sh` publishes only the client, conformance and CLI. A tagged
> release would publish a CLI that `npm install` cannot resolve. Change (src/scripts/cli_publish.sh):
>
> ```diff
> const packages = [
> ['packages/ui-api-client/package.json', '@qrun-io/qqq-ui-api-client'],
> + ['packages/core/package.json', '@qrun-io/qqq-core'],
> ['packages/conformance/package.json', '@qrun-io/qqq-ui-api-conformance'],
> ['packages/cli/package.json', '@qrun-io/qqq-cli'],
> ];
> @@
> npm publish -w @qrun-io/qqq-ui-api-client --registry https://npm.pkg.github.com --tag rc
> +npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag rc
> npm publish -w @qrun-io/qqq-ui-api-conformance --registry https://npm.pkg.github.com --tag rc
> npm publish -w @qrun-io/qqq-cli --registry https://npm.pkg.github.com --tag rc
> ```
>
> Core already has `publishConfig` for GitHub Packages, so the destination check passes. While there,
> `cli_verify.sh` should also run `npm run lint` (it now checks that the release versions agree) and
> diff `packages/core/src/api/operations.ts` next to `schema.ts`. Workaround until then: publish core
> by hand right after the orb job (`scripts/release.mjs` prints the command).
>
> **1b. Final versions as `latest` (needed for 1.0.0).** `cli_publish.sh` always publishes with
> `--tag rc`, so 1.0.0 would land under `rc`. Taken up in qqq-orb PR #165 (head 4119818) with the rule
> James accepted: if any of the four versions (client, core, conformance, CLI) has a prerelease part,
> all four publish as `rc`; only when all four are final, as `latest`. qqq-cli's `release.mjs` gate
> refuses a final CLI version while any of the four is a prerelease.
>
> **2. Opt-in platform lanes (not blocking 1.0.0-rc.1, wanted before 1.0).** The CLI ships binaries for
> macOS, Linux and Windows and has three keyring backends, but CI runs only Linux. Proposal: a new orb
> job `cli_platform` (the YAML below) and a manifest key `platforms: [macos-arm64, windows-x64]` that
> `cli_template` turns into one job per platform, required before publishing on tags. macOS and
> Windows executors cost more credits and need the plan to include `m4pro.medium` and
> `windows.medium`. A repository without the key gets exactly today's pipeline.
>
> Details and the job YAML: `docs/ci-lanes.md` in qqq-cli.
## Platform lanes in detail
Two lanes, both on pull requests and tags: **macOS arm64** (tier 1 in the design, Keychain) and
**Windows x64** (tier 2, DPAPI, PowerShell completion, the cross-compiled `.exe` actually run). Each
installs Node 20 and Bun 1.4.4, runs build, test and lint, builds the binary for its own platform with
`scripts/build-binaries.mjs --host` and smoke-runs it. Linux arm64 and musl can be added the same way
later (`arm.medium` machine executor, `node:20-alpine`).
### Option A (recommended, shipped in qqq-orb 0.8.33): an orb change
The `cli` profile is the one place CLI CI is defined, so the lanes belong there. Changes in `qqq-orb`:
1. a new job `src/jobs/cli_platform.yml` (the job below, with the executors inlined as parameters);
2. `cli_template` in `src/scripts/generate_pipeline.py` appends one `qqq-orb/cli_platform` job per
entry of a new optional manifest key, and makes `cli_verify_publish` require them on tags (sketch;
the manifest validation in `generate_pipeline.py` has to accept the key):
```python
platforms = config.get("platforms", []) # validated: subset of ("macos-arm64", "windows-x64")
jobs = [{"qqq-orb/cli_platform": {"name": f"cli-{p}", "platform": p, "filters": filters}} for p in platforms]
options["requires"] = [f"cli-{p}" for p in platforms] if publish else []
jobs.append({"qqq-orb/cli_verify_publish": options})
```
3. `cli_verify.sh`: `npm run lint`, and `operations.ts` in the drift check; `cli_publish.sh`: publish
`@qrun-io/qqq-core`; the image to a current `cimg/node:22.x`, plus a Node 20 run of `npm test`.
Then this repository opts in with two lines and no change to `.circleci/config.yml`:
```yaml
# .qqq-ci.yml
platforms: [macos-arm64, windows-x64]
```
### Option B: a second pipeline in this repository (not needed since 0.8.33)
If the orb change has to wait: CircleCI projects on the GitHub App integration can have more than one
pipeline, each with its own config file (Project Settings, Pipelines, "Add pipeline", config path
`.circleci/platforms.yml`, trigger on pushes and tags). The orb's setup pipeline stays as it is. The file
below validates with `circleci config validate` (CLI 1.13.0); it has not run on CircleCI, and the macOS
and Windows resource classes need the plan to include them. This option was not applied: the
repository's `.circleci/config.yml` is unchanged and still only the orb stub.
```yaml
version: 2.1
executors:
macos-arm64:
macos:
xcode: 16.4.0
resource_class: m4pro.medium
windows-x64:
machine:
image: windows-server-2022-gui:current
resource_class: windows.medium
shell: bash.exe
jobs:
cli_platform:
parameters:
platform:
type: executor
binary:
type: string
executor: << parameters.platform >>
environment:
BUN_VERSION: 1.4.4
NODE_VERSION: 20.20.2
steps:
- checkout
- run:
name: Node and Bun
command: |
if [[ "$OSTYPE" == darwin* ]]; then
brew install node@20 >/dev/null && echo 'export PATH="$(brew --prefix node@20)/bin:$PATH"' >> "$BASH_ENV"
curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION"
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> "$BASH_ENV"
else
nvm install "$NODE_VERSION" && nvm use "$NODE_VERSION"
powershell.exe -NoProfile -Command "\$env:BUN_INSTALL='C:\bun'; & ([scriptblock]::Create((irm https://bun.sh/install.ps1))) -Version $BUN_VERSION"
echo 'export PATH="/c/bun/bin:$PATH"' >> "$BASH_ENV"
fi
- run: node --version && bun --version
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run lint
- run:
name: Binary for this platform
command: node scripts/build-binaries.mjs --no-build --host
- run:
name: Binary smoke (scripts/verify-binary.sh / .ps1)
command: |
if [[ "$OSTYPE" == darwin* ]]; then
scripts/verify-binary.sh ./dist/<< parameters.binary >>
else
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-binary.ps1 -Binary dist/<< parameters.binary >>
fi
- store_artifacts:
path: dist
workflows:
platforms:
jobs:
- cli_platform:
name: macos-arm64
platform: macos-arm64
binary: qqq-darwin-arm64
- cli_platform:
name: windows-x64
platform: windows-x64
binary: qqq-windows-x64.exe
```
The smoke is `scripts/verify-binary.sh` (macOS, Linux) or `scripts/verify-binary.ps1` (Windows):
version with `binary with ui`, completion, `qqq ui --check` naming the build, and with `--server` /
`-Server` also login, doctor, whoami, a query and a process run. Without a QQQ server in CI the lanes
run the offline half; a lane with the sample server (Java 21) or a Prism mock can add `--server`. Both
scripts were run here: the `.sh` on darwin-arm64 (file keyring and the macOS Keychain) and in Debian
amd64, the `.ps1` with PowerShell 7 in `mcr.microsoft.com/powershell` on the linux-x64 binary, all
against the sample server. Neither has run on Windows: **Windows x64 and macOS x64 are unverified
until these lanes run.**
Notes on the YAML: Windows runs steps in Git Bash (`shell: bash.exe`), so the same commands work on
both lanes; `nvm` comes with the Windows image; `QQQ_CONFIG_DIR` keeps the smoke away from the agent's
profile. A Keychain round trip (`qqq login` against a Prism mock with `QQQ_EXTENSION_CHECK=off`, then
`logout`) is the next step for the macOS lane; on Windows the DPAPI store is covered by the kernel's unit
tests only.
+39
View File
@@ -0,0 +1,39 @@
# Extension documents in the client
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is the sample server's live
document for core (QQQ 4.1.0-SNAPSHOT, `develop` 6ac64fc66, UI API 1.8.0, which already carries esb)
merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`.
Rebuild it with the sample server running:
```sh
npm run spec:merge-extensions && npm run generate # writes openapi/extension-sources.json too
```
Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/openapi/extension-sources.json`):
| Extension | Version | Prefix | Operations | Repository at | Minimum the client needs |
|---|---|---|---|---|---|
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 6ac64fc66 (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 1aee6cb | |
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` bd50e1f | |
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8fed18e | |
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 7b302ed | |
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` a182076 | |
| printing | 2.1.0 | `/qbit/printing` | 17 | qbit-printing `feat/print-anything` | 2.0.0 (realtime-v1); 2.1.0 adds the table-list and text prints Ctrl+P uses |
219 operations in all, 153 from extensions (printing was appended on 2026-11-01 with
`scripts/merge-spec.mjs`, keeping the other documents as they were). No extension document conflicted with core or another
(identical shared schemas such as `BasicErrorResponseV1`). There is no `qbit-middleware-health`: the
health extension lives in qqq's `qqq-middleware-javalin`. Other qbits in the workspace (authentik,
jobs, help, middleware-mcp, ...) register no UI API extension.
Minimums are only set where an extension's own docs state one; the rest accept any version the
server lists. Each extension's minimum and realtime channels live in
`packages/core/src/extensions/registry.ts` (`FACTS`).
+96
View File
@@ -0,0 +1,96 @@
# Packaging qqq
The release artifact is **one `qqq` binary per platform with the terminal UI bundled** (`qqq ui`),
built by `npm run binaries` (`scripts/build-binaries.mjs`; `--cli-only` leaves the UI out) into
`dist/qqq-<os>-<arch>[.exe]` and uploaded to a GitHub release tagged `cli-v<version>` with
`SHA256SUMS`. `qqq --version` says `binary with ui`. Every install path below ships those files; there
is no separate `qqq-ui` binary. The npm package `@qrun-io/qqq-cli` (GitHub Packages, Node 20+) is the
CLI only and tells you where to get the UI. The full release sequence is in
[`docs/release-runbook.md`](../release-runbook.md).
| File | What | Where it goes |
|---|---|---|
| `scripts/install.sh` | curl installer: picks `qqq-<os>-<arch>[-musl]` of the newest stable `cli-v*` release (`--pre` for prereleases, `--version` to pin), checks it against `SHA256SUMS`, installs `qqq` to `~/.local/bin` (`--dir`; `--dry-run` prints the URL; `--help`) | served from `main`: `curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh \| sh` |
| `homebrew-tap/` | the whole `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb` (macOS arm64 and x64, Linux arm64 and x64; installs only `bin/qqq` plus bash, zsh and fish completions; its test insists on `binary with ui`), README, LICENSE, and the qqq-orb `homebrew-tap` CI profile | a new repository `QRun-IO/homebrew-tap` (`brew install qrun-io/tap/qqq`) |
| `qqq.json` | Scoop manifest for the with-UI Windows x64 binary, same shape as qctl's, `checkver` and `autoupdate` reading `SHA256SUMS` | `bucket/qqq.json` in `QRun-IO/scoop-bucket` (`scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq`) |
| `scripts/update-packaging.mjs` | fills version and sha256 in `homebrew-tap/Formula/qqq.rb` and `qqq.json` from a release's `SHA256SUMS` | run after each release |
| `scripts/verify-binary.sh`, `scripts/verify-binary.ps1` | smoke of an installed or downloaded binary: version (with ui), whoami, query, process run, `ui --check` | CI lanes and after-publish checks |
The formula was installed and tested with the linux-x64 with-UI binary in the `homebrew/brew` container
(install, `brew test`, the three completion files); it has not been installed on macOS (that would
change this machine's Homebrew).
## Release steps
See [`docs/release-runbook.md`](../release-runbook.md). In short: `node scripts/release.mjs`, tag, GitHub
release with `dist/qqq-*` and `dist/SHA256SUMS`, `node scripts/update-packaging.mjs <that SHA256SUMS>`,
copy `homebrew-tap/Formula/qqq.rb` to the tap and `qqq.json` to the bucket (the bucket's Excavator
also picks up new releases by itself). Use the uploaded sums, not a
local rebuild: each build stamps its commit, so checksums differ between builds. The script exits 1 and writes
nothing when a sum is missing or malformed, and never writes zeros; check the result anyway with
`git diff` and `grep -c 0000000000000000 docs/packaging/qqq.json docs/packaging/homebrew-tap/Formula/qqq.rb`
(both must print 0) before copying the files out. The tap's CI and the bucket's manifest tests would catch
zeros (a download that does not match) only if their pull requests are opened and their checks are read;
checkpoint the sums here first, not there.
## Where the formula and manifest live
- **`QRun-IO/homebrew-tap`** is laid out under `homebrew-tap/` here (the repository is created by copying
that directory). Its CI is the qqq-orb `homebrew-tap` profile (`.qqq-ci.yml`, with the same setup
`.circleci/config.yml` as `QRun-IO/homebrew-qctl`). That CI fetches and checksums every platform's
download, but it installs the formula and runs `brew test` with the **Linux x64 binary only**; the
macOS (and Linux arm64) binaries are not executed there. qctl stays in `QRun-IO/homebrew-qctl` until it
moves over.
- **`QRun-IO/scoop-bucket`** is created from
[ScoopInstaller/BucketTemplate](https://github.com/ScoopInstaller/BucketTemplate), with `qqq.json` as
`bucket/qqq.json`. Its CI is the template's GitHub Actions (manifest tests), and the template's
Excavator workflow runs `checkver`/`autoupdate` on a schedule, so a new `cli-v*` release updates the
manifest (version and hash from `SHA256SUMS`) without a manual pull request.
## Platform notes
- **macOS**: Bun signs the binaries ad hoc; there is no Developer ID signature or notarization yet (it needs
James's Apple Developer account, see the runbook). Homebrew and the curl installer do not set the
quarantine flag, so they just work. A binary downloaded with a browser is quarantined, and Gatekeeper
refuses it ("qqq cannot be opened because Apple cannot check it for malicious software" or "... is
damaged"). Clear the flag once: `xattr -d com.apple.quarantine ./qqq-darwin-arm64` (or right-click,
Open, in Finder; or System Settings, Privacy and Security, Open Anyway).
- **Linux musl** (Alpine): `qqq-linux-<arch>-musl` needs `apk add libstdc++ libgcc`. The installer picks
the musl build on Alpine and other musl hosts.
- **x64**: built on Bun's baseline runtime, so the binaries run on CPUs without AVX2 and under Rosetta 2
or QEMU.
- **Windows**: `qqq-windows-x64.exe` is cross-compiled and not Authenticode-signed (James's certificate,
see the runbook), so SmartScreen warns on first run ("More info", "Run anyway"); Scoop installs are not
affected. Sign-ins use DPAPI through PowerShell. **Unverified**: it has never run; the Windows CI lane
(`docs/ci-lanes.md`, `scripts/verify-binary.ps1`) is the first place it will.
- **macOS x64**: cross-compiled and **unverified** (no Intel Mac or Rosetta here) until the CI lanes run.
- Binaries with the UI are 100.6 MB (darwin-arm64) to 129.2 MB (windows-x64); the Bun runtime is embedded.
## The terminal UI (`qqq ui`)
The TUI (`packages/tui`) runs on Bun only: OpenTUI loads a native Zig library through `bun:ffi`, and
the React renderer sits on top. Three ways to ship it were tried on 2026-10-31 (Bun 1.4.4, OpenTUI
0.8.1) against the sample server:
| Option | Result |
|---|---|
| **One `qqq` binary with the UI** (`npm run binaries -- --with-tui`) | **Works.** Entry `scripts/binary-with-tui.ts`; `qqq ui` runs the bundled TUI in the same process. `bun build --compile` embeds OpenTUI's native library for the target (installed for every platform with `bun install --os='*' --cpu='*'`). The full tmux smoke (`packages/tui/scripts/smoke.sh`: home, grid, record paging, filter, contingent form, async process, menu, help, dashboard, quit) passed with the darwin-arm64 binary; `qqq ui table get person 3` opened the record in tmux in Docker with the linux-x64 (glibc, under emulation) and linux-arm64-musl binaries, and quit with exit 0. The CLI commands of the same binary behave as before. |
| A second binary `qqq-ui` (entry `packages/tui/src/main.tsx`) | Works too (same smoke passed), 103 MB on darwin-arm64. It already contains the whole CLI program (the TUI's command line runs `runCli`), so a second binary is almost a second copy of the first. |
| npm only, Bun as a prerequisite | Possible but the weakest: the TUI package is private today, `bun` must be installed, and OpenTUI's native package comes from the optional dependencies at install time. |
Sizes, CLI only → with the UI: darwin-arm64 84.8 → 100.8 MB, darwin-x64 95.1 → 111.8 MB, linux-x64
105.5 → 123.3 MB, linux-arm64 105.6 → 122.3 MB, windows-x64 111.4 → 129.5 MB, linux musl about 98 →
122 to 125 MB. The UI adds 16 to 18 MB (the native library is 6.6 MB on darwin-arm64; the rest is React,
OpenTUI and its React renderer).
Not verified: the Windows and macOS x64 builds with the UI (cross-compiled, never run; Windows Terminal
was never checked even for the spike). Linux glibc arm64 with the UI passed the Docker tmux check before
the 98e426b merge, linux-x64 and linux-arm64-musl after it.
**Decision (James, 2026-10-31):** one `qqq` binary per platform with the UI is the release artifact,
from the same release, Homebrew formula, Scoop manifest and installer. `npm run binaries` and
`release.mjs` bundle the UI by default (`--cli-only` is the escape hatch). Both binary entries start the
built CLI (`packages/cli/dist`), the module the TUI imports as `@qrun-io/qqq-cli/program`, so the CLI
program is bundled once; that saved only 0.3 MB (darwin-arm64 100.8 → 100.5 MB), because the program
is small next to React, OpenTUI and its native library. After the TUI batch merge (98e426b) the with-UI
darwin-arm64 binary is 100.6 MB and passes `smoke.sh` and `smoke-widths.sh`.
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
ignore: /.*/
+5
View File
@@ -0,0 +1,5 @@
profile: homebrew-tap
orb_version: '0.8'
default_branch: main
tap: qrun-io/tap
formula: qqq
@@ -0,0 +1,60 @@
# frozen_string_literal: true
# qqq: the QQQ command-line client and terminal UI (qqq ui) in one binary.
# Kept in qqq-cli at docs/packaging/homebrew-tap/Formula/qqq.rb; scripts/update-packaging.mjs fills
# version and sha256 from the SHA256SUMS of a GitHub release, then the file is copied to the tap.
class Qqq < Formula
desc "Command-line client and terminal UI for QQQ applications"
homepage "https://github.com/QRun-IO/qqq-cli"
version "1.0.0"
license "Apache-2.0"
on_macos do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
end
end
on_linux do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
end
end
def install
binary = Dir["qqq-*"].first
chmod 0755, binary # downloads arrive without the execute bit; completions below run the binary
bin.install binary => "qqq"
generate_completions_from_executable(bin/"qqq", "completion", shells: [:bash, :fish])
# `qqq completion zsh` is an eval script; an fpath file needs the #compdef form
(zsh_completion/"_qqq").write <<~ZSH
#compdef qqq
local -a candidates
candidates=("${(@f)$(qqq __complete -- "${words[@]:1:$CURRENT-1}" 2>/dev/null)}")
compadd -- "${candidates[@]}"
ZSH
end
def caveats
<<~EOS
qqq ui opens the terminal UI (bundled in this binary). Sign in first:
qqq login --server https://your-qqq-server
EOS
end
test do
# the release binaries carry the terminal UI; a CLI-only build here would be a packaging mistake
assert_match(/qqq #{Regexp.escape(version.to_s)} \(.* binary with ui/, shell_output("#{bin}/qqq --version"))
assert_match "table", shell_output("#{bin}/qqq __complete -- ta")
end
end
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+28
View File
@@ -0,0 +1,28 @@
# QRun-IO Homebrew tap
Homebrew formulae for QRun-IO command-line tools.
```sh
brew install qrun-io/tap/qqq # taps QRun-IO/homebrew-tap on first use
qqq --version # "... binary with ui ..."
qqq login --server https://your-qqq-server
qqq ui # the terminal UI, in the same binary
```
| Formula | What | Source |
|---|---|---|
| `qqq` | the QQQ command-line client and terminal UI, one binary (macOS arm64 and x64, Linux arm64 and x64) | [QRun-IO/qqq-cli](https://github.com/QRun-IO/qqq-cli) |
`qctl` lives in [QRun-IO/homebrew-qctl](https://github.com/QRun-IO/homebrew-qctl) for now; it can move
here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a pointer.
## Updating
The formula is generated in qqq-cli: after a `cli-v<version>` GitHub release, run
`node scripts/update-packaging.mjs <release SHA256SUMS>` there and copy
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every
platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but
only once the pull request here is opened and its checks are read. The script refuses to write missing or
malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR.
Apache-2.0
+36
View File
@@ -0,0 +1,36 @@
{
"version": "1.0.0",
"description": "Command-line client and terminal UI (qqq ui) for QQQ applications, in one binary",
"homepage": "https://github.com/QRun-IO/qqq-cli",
"license": "Apache-2.0",
"notes": [
"qqq ui (the terminal UI) needs Windows Terminal or another ConPTY terminal; the legacy console is not supported.",
"Sign in with: qqq login --server https://your-qqq-server"
],
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
}
},
"bin": [
[
"qqq-windows-x64.exe",
"qqq"
]
],
"checkver": {
"github": "https://github.com/QRun-IO/qqq-cli",
"regex": "cli-v([\\d.]+(?:-rc\\.\\d+)?)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v$version/qqq-windows-x64.exe",
"hash": {
"url": "$baseurl/SHA256SUMS"
}
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
# CLI parity plan (to 1.0)
Goal (James, 2026-10-31): every row of the CLI column in `feature-matrix.md` is **pass**, **n/a** (with the
browser-only reason) or **blocked** (citing a server finding in `docs/server-findings.md`). No `partial` or
`todo` at the end. The status source is `STATUS` in `scripts/parity-matrix.py`; regenerate with
`python3 scripts/parity-matrix.py docs/parity/next-feature-matrix.md`.
Start: 173 pass / 42 partial / 80 todo / 106 n/a of 401 (branch `feat/m1-cli-parity` from `feat/m1-kernel-cli` 6c56b89).
Checkpoint 1 (bulk load): 188 pass / 41 partial / 66 todo / 106 n/a.
Checkpoint 2 (records, queries, processes, help): 232 pass / 1 partial / 60 todo / 106 n/a / 2 blocked.
Checkpoint 3 (navigation, widgets, reports, security): 290 pass / 0 partial / 0 todo / 107 n/a / 4 blocked. Blocked: QRY-085 (finding 24, qqq#1355), REC-063 (finding 19, qqq#1351), SEC-029 and SEC-030 (finding 28, qqq#1359: no AUTH_0 server; SEC-030 also needs tenant credentials from James, qqq-cli#10). New n/a: WID-074 (standalone composite controls are browser input editors).
"unit-tested" in a row note means the sample at :8077 cannot exercise the row (no fixture); the kernel and the
command are covered by node tests (and a local stub server where a command is involved). Everything else is
verified live against :8077.
Status column: **plan** = what the row becomes; **done** once the STATUS source says so.
## Bulk load, bulk edit, bulk delete (first)
| Row | Plan | Status |
|---|---|---|
| PRC-029 Bulk edit, switched-on fields | `qqq table bulk-edit <t> -r ids\|-w … --set f=v --clear f`; kernel `bulkEditValues` sends `bulkEditEnabledFields`; review shown, `--yes` | done (pass) |
| PRC-030 Bulk delete | `qqq table bulk-delete <t> -r ids\|-w …`: review, validation, result; asks unless `--yes` | done (pass) |
| PRC-031 Bulk insert | `qqq table bulk-insert <t> --file x.csv [--mapping m.yaml] [-i]`: upload, mapping (server suggestion cleaned, finding 26), value mapping, review preview, result | done (pass) |
| PRC-032 Mapping validation, Back | local mapping checks before submit (unmapped column, required without value, layout, key fields); `b` at a mapping or review prompt goes back (isStepBack) | done (pass) |
| PRC-016 Back (moved here) | verified on bulk insert -i | done (pass) |
| PRC-033 Bulk edit with a file by key fields | `qqq table bulk-edit <t> --file x.csv --key f[,g]` runs `bulkEditWithFile` | done (pass) |
| PRC-034 Upload instructions and template | instructions printed as text; `--template flat\|tall\|wide [--out]` saves the data: URI template | done (pass) |
| PRC-046 Saved profiles | `qqq bulk-profile list\|show\|save\|rename\|delete <t>`, `--saved <name>` on bulk-insert/bulk-edit; sample has no saved-profile processes: unit-tested with a stub | done (pass) |
| PRC-058 Saved profile menu | new, update after confirm (diff of changes printed), rename, delete | done (pass) |
| PRC-059 Ownership | yours vs shared; only the owner may save, rename, delete (checked before the call) | done (pass) |
| PRC-060 File mapping fidelity | field help, column preview values, repeated-header warning, `--no-header` | done (pass) |
| PRC-061 Typed defaults, searchable value mapping, add fields | defaults typed per field (`--default f=v`, labels resolved), value mapping by label search, `-i` add-fields list grouped by table | done (pass) |
| QRY-031 Bulk edit with checked ids | `-r ids` | done (pass) |
| QRY-032 Bulk delete of the whole query result | `-w`/`--filter-json` sends the filter | done (pass) |
| QRY-034 Actions menu | `qqq table actions <t>`: bulk load, bulk edit/delete, table processes, with the command for each | done (pass) |
## Records, forms, adornments
| Row | Plan | Status |
|---|---|---|
| REC-002 placeholders, hidden | unit-tested (finding 17) → pass | done |
| REC-020 HTML field editing | `table update --editor <field>` opens $EDITOR; HTML shown as sanitized text | done |
| REC-029 CHIP colors and icons | color (ANSI on a terminal) and icon name shown; unit-tested | done |
| REC-031 CODE_EDITOR | code shown verbatim (indent kept); `--editor <field>` edits it; unit-tested | done |
| REC-032 RENDER_HTML | HTML to sanitized text; unit-tested | done |
| REC-033 REVEAL | masked, `--reveal`, `--copy <field>` copies the value (OSC 52 / pbcopy); unit-tested | done |
| REC-034 FILE_DOWNLOAD | name and `table download`; unit-tested (finding 16 for BLOBs without adornments) | done |
| REC-035 FILE_UPLOAD formats | accepted formats checked before upload; unit-tested | done |
| REC-036..041 tooltip, widget, error, help | unit-tested → pass | done |
| REC-042/043 audits | unit-tested with a stub → pass | done |
| REC-053 API docs & playground | `table apis`, `api-doc`; playground: `table api-call` against the application API, or n/a | done |
| REC-054 scripts | add test runs if the server offers the test process; else note | done |
| REC-060 adjusters | unit-tested → pass | done |
| REC-061 dynamicForm widget on a record form | edit the JSON field through the widget's fields | done |
| REC-063 variant writes | blocked: finding 19 (qqq#1351) | done (blocked) |
| REC-064 joined sections | verified with an association join → pass | done |
| REL-006/007/008 | unit-tested → pass | done |
## Query, filters, saved views, variants
| Row | Plan | Status |
|---|---|---|
| QRY-004 column persistence | `qqq table columns <t> [--set a,b \| --reset]`, kept per table in the config the TUI uses; queries default to it | done |
| QRY-015 labels in filters | `-w "homeCityId = 'New York'"` resolved to ids by possible values | done |
| QRY-020/022 joins, stats | unit-tested → pass | done |
| QRY-050..054, 094, 096 saved views | unit-tested with a stub (sample has no saved views) → pass | done |
| QRY-060 variant remembered | last variant per table kept in local state | done |
| QRY-061/066/071 variants | unit-tested → pass | done |
| QRY-085 weekday criteria | grammar sends WeekdayOfDate; blocked on finding 24 (qqq#1355) | done (blocked) |
## Processes
| Row | Plan | Status |
|---|---|---|
| PRC-037 ad hoc composite actions and inputs | buttons offered as a choice, inputs prompted, submit-on-enter | done |
## Navigation, widgets, dashboards
| Row | Plan | Status |
|---|---|---|
| NAV-011, WID-040, PRF-004 | `qqq apps show <app> --widgets`: every widget once, declared order, gridColumns noted | done |
| NAV-017 | `qqq home`: counts, quick actions, app cards | done |
| NAV-024 | `qqq find <text>`: apps, tables, processes, reports by label with type and app | done |
| NAV-028 | reports under their app; unit-tested | done |
| WID-006/011/012/020/022/026/027/028/031/032/041/052/056/057/067/068 | render model per type, unit-tested and live where the sample has one | done |
| WID-021 process widget | runs its process through the runner | done |
| WID-029/030/070..075 editors | read-only rendering where it is display; editing through the host record field; grid-only rows n/a | done |
| WID-049 required dropdowns | prompt or refuse with the backend message | done |
## Reports (sample has none)
| Row | Plan | Status |
|---|---|---|
| RPT-001..006, 008, 019, 020 | `report run` formats csv/xlsx/json, inputs, summary/pivot files, navigation; stub server tests | done |
| RPT-009..018 | saved reports (`qqq saved-report list\|show\|run`), schedules, sharing (`share list\|add\|scope\|remove`); stub server tests | done |
| RPT-011 | streaming report route | done |
## Security and sign-in
| Row | Plan | Status |
|---|---|---|
| SEC-002/008 DISABLED tables and processes | listed with the reason; use refused before the call; unit-tested | done |
| SEC-006 copy needs insert | `table copy` refused without insert permission | done |
| SEC-011 record-level security | server-enforced; listed as returned | done |
| SEC-029/030 AUTH_0 | sign-in from the tenant, client id and audience (unit-tested); no AUTH_0 server to run it against | blocked (finding 28, qqq#1359; SEC-030 also qqq-cli#10) |
| SEC-034..037 TABLE_BASED | `qqq login --username` over HTTP Basic (the server's TableBasedAuthenticationModule creates the session and sets the sessionId cookie); stub-server tested | done |
## Help
| Row | Plan | Status |
|---|---|---|
| HLP-001..004 | unit-tested (finding 17) → pass | done |
+446
View File
@@ -0,0 +1,446 @@
# Terminal clients parity matrix
Generated by `scripts/parity-matrix.py` from the Next UI acceptance matrix (`qqq-frontend-next/docs/acceptance/feature-matrix.md`,
401 rows). Same ids; two columns added. **pass** = verified against the local sample server in this repo's
development; **partial** = works with the noted gap; **todo** = planned; **n/a** = browser-only, with the reason; **blocked** = needs a server change (the note cites the finding in docs/server-findings.md). "unit-tested" in a note: the sample has no fixture, so node tests (and stub-server CLI tests) verify it.
The TUI column is `qqq ui` (M3); its notes start with "TUI:".
## Summary (CLI column)
| Family | rows | pass | todo | partial | blocked | n/a |
|---|---|---|---|---|---|---|
| HLP | 6 | 5 | 0 | 0 | 0 | 1 |
| INT | 14 | 5 | 0 | 0 | 0 | 9 |
| NAV | 40 | 13 | 0 | 0 | 0 | 27 |
| PRC | 60 | 48 | 0 | 0 | 0 | 12 |
| PRF | 5 | 5 | 0 | 0 | 0 | 0 |
| QRY | 70 | 48 | 0 | 0 | 1 | 21 |
| REC | 65 | 53 | 0 | 0 | 1 | 11 |
| REL | 9 | 9 | 0 | 0 | 0 | 0 |
| RPT | 20 | 20 | 0 | 0 | 0 | 0 |
| SEC | 43 | 29 | 0 | 0 | 2 | 12 |
| WID | 69 | 55 | 0 | 0 | 0 | 14 |
| **all** | 401 | 290 | 0 | 0 | 4 | 107 |
## Summary (TUI column)
| Family | rows | pass | todo | partial | blocked | n/a |
|---|---|---|---|---|---|---|
| HLP | 6 | 6 | 0 | 0 | 0 | 0 |
| INT | 14 | 10 | 0 | 0 | 0 | 4 |
| NAV | 40 | 20 | 0 | 0 | 1 | 19 |
| PRC | 60 | 55 | 0 | 0 | 1 | 4 |
| PRF | 5 | 5 | 0 | 0 | 0 | 0 |
| QRY | 70 | 61 | 0 | 0 | 0 | 9 |
| REC | 65 | 59 | 0 | 0 | 0 | 6 |
| REL | 9 | 9 | 0 | 0 | 0 | 0 |
| RPT | 20 | 20 | 0 | 0 | 0 | 0 |
| SEC | 43 | 30 | 0 | 0 | 1 | 12 |
| WID | 69 | 56 | 0 | 0 | 0 | 13 |
| **all** | 401 | 331 | 0 | 0 | 3 | 67 |
## Rows
| ID | Feature | CLI | TUI | Note |
|---|---|---|---|---|
| NAV-001 | Direct link to a table list renders seeded rows | n/a | pass | browser shell; TUI: qqq ui table query <t>; :table query <t> |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | pass | browser shell; TUI: Apps tree, backend order |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | pass | browser shell; TUI: nested apps in the tree |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | pass | browser shell; TUI: Enter opens; current entry marked; arrows expand and collapse |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | n/a | browser shell; TUI: icon glyphs are a browser icon font; the TUI marks processes and reports and shows the icon name on app cards |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | pass | hidden tables and processes are not listed; direct use is refused by the server; TUI: hidden tables and processes are not listed |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | pass | qqq table get on a hidden table the user may read; TUI: :table query and :record open hidden tables the user may read |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | pass | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets; TUI: app home: sections with tables, processes, reports |
| NAV-009 | App home table entries show the backend record count | pass | pass | qqq apps show counts each table with TABLE_COUNT (--no-counts skips); TUI: record counts beside tables |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | pass | app home rendering |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | pass | pass | qqq apps show <app> --widgets: one labelled section per permitted declared widget, loaded once each; verified live on SampleWidgetsDashboard; TUI: dashboard apps draw their widgets |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | n/a | browser shell; TUI: logo, favicon and accent images are browser-only; the app name is in the title bar and on Home |
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized | n/a | n/a | browser shell |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | pass | browser shell; TUI: title-bar path: apps, table, record or form |
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | n/a | n/a | browser shell |
| NAV-016 | The site root redirects to the dashboard | n/a | n/a | browser shell |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata | pass | pass | qqq home: counts of what you may open, quick actions (permitted processes in your apps, with the command) and one card per app; verified live; TUI: Home: greeting, counts, recents, quick actions, app cards, work |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | n/a | pass | browser shell; TUI: qqq ui <route in the CLI grammar> |
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | n/a | n/a | browser shell |
| NAV-020 | Browser back and forward traverse navigation history with the right content | n/a | n/a | browser shell |
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | n/a | n/a | browser shell |
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | n/a | n/a | browser shell |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | pass | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2; TUI: g: primary key or a configured unique key (kernel gotoKeys) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | pass | pass | qqq find <text> [--type]: apps, tables, processes and reports by label with type, app and the command that opens each (the terminal's palette); verified live; shell completion covers names; TUI: F2, /, ., Ctrl+K palette with type and app context |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | pass | qqq search; TUI: the palette (F2, /, ., Ctrl+K) jumps to navigation targets and recently viewed records; live |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | n/a | n/a | browser shell |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | pass | qqq whoami: apiVersion, features, counts; TUI: F9 › Connection: UI API version, features, extensions, metadata generation and age, client build; live |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | pass | pass | qqq report list (with app) and apps show list reports in the app's Reports group, report run opens them; unit-tested and stub-server tested (the sample has no reports); TUI: reports listed under their app and in the tree, palette and F9; Enter runs one (format chosen, process screens, file saved) (unit-tested only; the sample has no reports) |
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | n/a | n/a | browser shell |
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | blocked | qqq search over POST /search; TUI: the palette calls POST /search; the sample lists recordSearch with no provider (server finding 21); live on fix branch 8bf6a5a66: recordSearch is no longer advertised and the palette says so; POST /search still has no provider (finding 21) |
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | pass | pass | server-enforced; refusals shown; TUI: server-enforced; the palette shows only what the server returns |
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a s | n/a | n/a | browser shell |
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authe | n/a | n/a | browser shell |
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{tab | n/a | n/a | browser shell |
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | n/a | pass | browser shell; TUI: Esc returns to the record or query, cursor kept |
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScr | n/a | n/a | browser shell |
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Esca | n/a | n/a | browser shell |
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes | n/a | n/a | browser shell |
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views | n/a | n/a | browser shell |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the brand | n/a | n/a | browser shell |
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | pass | pass | 10,000 rows with --all and --page-size; TUI: fake big server 10,000 rows x 40 columns at 160x45 (scripts/perf-large-table.sh): first page 95-100 ms with col 1/40, End to record 10,000 in 39-45 ms, to column 40 in 343-364 ms; budget 2,000 ms per step |
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | pass | pass | --limit/--skip paging; TUI: server-paged grid, page = visible rows |
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | pass | pass | server sort and filter; TUI: server sort and filter |
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budge | pass | pass | apps show --widgets loads every widget once, all at once, in declared order: 24 widgets (statistics, charts, 100-row tables) answering in 150 ms each finish well under 3 s; stub-server tested; TUI: dashboards load widgets as they come into view, 4 at a time (fake 3000-widget instance) |
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result | pass | pass | process run polls every 500 ms and prints progress; TUI: progress N/M, elapsed, 250 ms polling |
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list res | pass | pass | process run -r ids; TUI: grid selection or record, ids sent |
| PRC-002 | All-records run from a filter selection with no criteria | pass | pass | --filter-json; TUI: without a selection: the grid filter |
| PRC-003 | Zero-record runs from a filter that matches nothing | pass | pass | TUI: a filter that matches nothing runs as the server decides (zero records) |
| PRC-004 | Processes launched from an app (no records) | pass | pass | |
| PRC-005 | Process launched from selected rows of a table query | n/a | pass | grid selection; use -r; TUI: Space selects, b runs a table process |
| PRC-006 | Minimum and maximum input records | pass | pass | checked before init (filters counted); the sample declares no limits, verified by tests; TUI: kernel checkInputRecords before init |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | pass | pass | every component in order as text (kernel screen model); bulk load forms belong to qqq bulk; TUI: kernel screen blocks: forms, view, records, review, summary, help, HTML, widgets, downloads |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | pass | pass | text with line breaks; previewText printed above it; TUI: HELP_TEXT and its preview from the kernel screen |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | pass | pass | includeFieldNames and values kept across steps; TUI: includeFieldNames via the kernel runner |
| PRC-010 | Required input validation on screens and in the backend | pass | pass | required checked locally and by the server; TUI: required checked by field before submit |
| PRC-011 | Process possible-value fields | pass | pass | labels or ids, prompts list choices; TUI: picker |
| PRC-012 | VIEW_FORM labels and backend values | pass | pass | label: value on every step; TUI: summary shows view fields |
| PRC-013 | HTML component renders the step html value, sanitized | pass | pass | {step}.html as text (tags stripped); TUI: HTML component as text from the kernel screen |
| PRC-014 | DOWNLOAD_FORM delivers the generated file | pass | pass | GET /download/{file} saved to --out-dir, else the download directory; no sample process offers one, verified by tests; TUI: w saves the DOWNLOAD_FORM file through the save dialog (unit-tested only) |
| PRC-015 | Step help content for process screens | pass | pass | step helpContents shown on the screen, process help at start and in describe; none in the sample; TUI: step and process help in the screen |
| PRC-016 | Back to the backend back step with entered values | pass | pass | b at a prompt when the step reports backStep (isStepBack=true); verified live: bulk insert -i back from the mapping to the upload and from the review to the mapping; TUI: F4 goes back to the backend back step (process screen and bulk wizard); live |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | pass | updatedFrontendStepList followed; TUI: kernel runner |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | pass | updatedFields applied; TUI: kernel runner |
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | pass | message, N/M, elapsed on stderr; TUI: bar, N/M, message, elapsed |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | blocked | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130; TUI: Esc asks, then POST cancel; the sample accepts the cancel and keeps the run (server finding 13); still blocked on the fix branch (finding 13) |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | pass | userFacingError shown; TUI: failure message shown; F5 re-runs |
| PRC-022 | Retry after an error restarts with the original record selection | n/a | pass | retry is re-running the command; TUI: F5 or r runs again with the same records |
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | n/a | pass | screen labels; TUI: Ctrl+S continues; the last screen closes with Enter or Esc |
| PRC-024 | Linear stepper and step position | n/a | pass | stepper; step names printed; TUI: step list with marks |
| PRC-025 | RECORD_LIST paging and page size over process records | pass | pass | GET …/records paged into a table, --record-limit; TUI: RECORD_LIST pages with n / p through getProcessRecords (unit-tested only) |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | pass | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation; TUI: validate or skip, then run |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | pass | status, count, message lines; TUI: summary lines colored by status |
| PRC-028 | Process summary results after execution and persisted effects | pass | pass | |
| PRC-029 | Bulk edit: switched-on fields only, review and result | pass | pass | qqq table bulk-edit <t> -r ids/-w ... --set f=v --clear f (-i chooses fields): only the switched-on fields are sent (bulkEditEnabledFields), review, result; verified live on person; TUI: bulk edit: Space switches fields on, Enter sets typed values (picker, yes/no), review, result; live on person |
| PRC-030 | Bulk delete: review, validation and result | pass | pass | qqq table bulk-delete <t> -r ids/-w ...: review with count and preview, validation, result; asks unless --yes (exit 2 without a terminal); verified live; TUI: bulk delete: review with count and preview, validation choice, confirm, result; live on person |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | pass | pass | qqq table bulk-insert <t> --file x.csv: upload, column mapping (server suggestion cleaned per finding 26, --mapping document, --column/--default, -i), value mapping, review preview and validation, result; FLAT and TALL verified live; TUI: bulk insert: file navigator upload, column mapping grid, value mapping, review preview, result; live: 2 person rows from CSV (then deleted) |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | pass | pass | mapping checked before submit (unmapped field, unknown column, layout, WIDE repetition, key fields, repeated header); Back from mapping to upload and from review to mapping verified live with -i; TUI: mapping checked before Ctrl+S (kernel checkMapping), F4 back through mapping and upload (unit-tested only) |
| PRC-033 | Bulk edit with a file matched by key fields | pass | pass | qqq table bulk-edit <t> --file x.csv --key id [--mapping]: rows matched by the key, value mapping; verified live on person; TUI: bulk edit with a file: key fields (k) from the server, mapping, review, result (unit-tested only) |
| PRC-034 | Upload instructions HTML with downloadable template | pass | pass | upload instructions printed as text; --template [flat/tall/wide] [--out] saves the data: templates; verified live; TUI: a: save the bulk insert template file(s) from the upload instructions; live on person (3 templates) |
| PRC-035 | Named WIDGET fetched with the process UUID and values | pass | pass | named widget with the process UUID and values, kernel render model; none in the sample, verified by tests; TUI: named WIDGET fetched with the process UUID and values, drawn from the kernel screen (unit-tested only) |
| PRC-036 | Named composite WIDGET seeded from process values | pass | pass | seeded from the process value of the widget name; TUI: composite widget blocks seeded from process values drawn as text (kernel screen; unit-tested only) |
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | pass | pass | ad hoc composite: ${} interpolation, conditional blocks left out, INPUT_FIELD blocks asked as step fields (submitOnEnter submits), BUTTON actionCodes offered as a choice (-v actionCode=…), controlCode buttons change only what shows; unit-tested (the sample has no ad hoc widget); TUI: ad hoc composite widgets: interpolation, conditional blocks hidden, INPUT_FIELD inputs (Enter submits when asked), BUTTON actionCode sent with the step (unit-tested only) |
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | n/a | n/a | Google Drive is web-only |
| PRC-039 | Google Drive folder selection through the Google Picker | n/a | n/a | Google Drive is web-only |
| PRC-040 | Reload mid-process starts a fresh run | n/a | n/a | no reload; TUI: no reload in a terminal; F5 re-runs |
| PRC-041 | Expired session mid-process returns to sign-in | pass | pass | UNAUTHENTICATED exits 3 with the sign-in hint; TUI: a 401 shows the error; :signout and F9 sign in again |
| PRC-042 | Process permission enforcement | pass | pass | server-enforced; TUI: hidden processes are not offered; the server refuses the rest |
| PRC-043 | Default process values from the link (defaultProcessValues) | pass | pass | -v values seed every step; TUI: :process run P -v name=value seeds the values |
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | n/a | pass | scanner screens are interactive; TUI: scanner-format steps: no heading or step list, Enter in the input submits (unit-tested only; no sample scanner process) |
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | pass | pass | the runner follows nextStep whatever the flow; TUI: the runner follows nextStep whatever the flow |
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | pass | pass | qqq bulk-profile list/show/save/rename/delete <t> [--edit], bulk-insert --saved <name> and --save-profile; unit-tested and stub-server CLI tests: the sample has no saved-profile processes; TUI: P on the mapping screen: use, save, update, delete saved profiles (unit-tested only; the sample has none) |
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | pass | completes on init |
| PRC-048 | Process-field possible values load and search through the v1 API | pass | pass | searchProcessPossibleValues |
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | pass | pass | v1 routes only; TUI: v1 process routes through the kernel |
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScree | n/a | pass | Material-specific; TUI: every process the metadata gives the table is in the bulk bar and F9 |
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | pass | pass | dependent choices narrow after each answer; TUI: contingent picker narrows |
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, | pass | pass | bulk-profile save --replace prints the changes and asks (--yes), rename, delete; stub-server CLI tests (the sample has no saved-profile processes); TUI: update a saved profile after a confirm listing the changes (kernel diffMappings), new profile, delete (unit-tested only) |
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a p | pass | pass | profiles listed as yours, then shared by their owner; save over, rename and delete refused for a profile you do not own; stub-server CLI tests; TUI: profiles listed as yours or shared by their owner; update and delete only for the owner (unit-tested only) |
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle | pass | pass | -i lists columns with preview values and the step's field help, asks for the header row (--no-header), warns on repeated headers (only the first is read); header toggle and previews verified live, help and repeated headers unit-tested; TUI: mapping grid: field help of the row, column previews, required marks, repeated-header warnings, header row toggle h; live |
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material | pass | pass | defaults typed per field (--default f=label resolves possible values; yes/no booleans), value mapping by label or id with choices listed, -i add-fields list grouped by table; verified live (Dog/Cat to species ids, yes/no to booleans); TUI: typed defaults (picker for possible values, yes/no), searchable value mapping picker, searchable field source menu (unit-tested) |
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | n/a | pass | composite editors; TUI: composite INPUT_FIELD editors are step fields: required checks before sending, a BLOB input goes as a multipart file (unit-tested only) |
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | n/a | pass | TUI: the first field has focus; behaviors trim on set |
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | pass | pass | process values typed local, sent UTC; screens show the local zone; TUI: step DATE_TIME inputs edited in the local zone, sent as UTC (kernel datetime) |
| PRC-065 | Configured Google Drive shared-folder selection and process submission | n/a | n/a | Google Drive |
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | pass | pass | field behaviors applied before submit; TUI: field behaviors via FormSession |
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | pass | saved views and column UI; TUI: s sorts, v hides, orders, sizes and pins columns, F6 filters; live on person |
| QRY-091 | Column menu copies page or full query values in backend sort order | n/a | n/a | saved views and column UI; TUI: clipboard copy of column values |
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | n/a | pass | saved views and column UI; TUI: v: / searches, columns grouped by table and join with shown counts, * marks changes; live |
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | n/a | pass | no screen state to keep: views are opened by name; TUI: view settings (columns, widths, sort) persist per table |
| QRY-094 | Counted quick views render and open their saved-view route | pass | pass | saved-view list counts quick views with doCount (--counts counts all); unit-tested (no sample saved views); TUI: doCount views show their record counts in F9 Saved views (unit-tested only; no sample saved views) |
| QRY-095 | A new query defaults to fifty rows per page | n/a | n/a | saved views and column UI; TUI: page size follows the terminal height |
| QRY-096 | A saved view reconciles removed fields and warns the user | pass | pass | opening a view drops removed fields with Material's warnings on stderr; unit-tested (no sample saved views); TUI: opening a view drops removed fields and flashes the kernel warnings (unit-tested only) |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | pass | default order by primary key |
| QRY-002 | Pagination with Material page sizes and URL state | pass | pass | --limit, --skip, --all; TUI: PgUp/PgDn, Ctrl+F/B, Home/End across pages |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | pass | --order, -field for descending; TUI: s cycles asc, desc, default |
| QRY-004 | Column visibility and order persist per table | pass | pass | qqq table columns <t> [fields…/--reset]: kept per table in the config the TUI column chooser uses; table query defaults to them, --fields wins; verified live; TUI: v: columns on/off and order, kept per table in the config file |
| QRY-005 | Column widths, density and card view | n/a | pass | grid presentation; TUI: v: widths, fit or fill, zebra, density |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | n/a | pass | TUI: Esc lands on the row last viewed, filter and page kept |
| QRY-007 | A failed query reports the backend error with a retry | pass | pass | error with code, status and trace id; TUI: error shown in the grid; F5 retries |
| QRY-010 | Text operators match Material labels and backend semantics | pass | pass | all operators and shorthand; text matches (~, CONTAINS, STARTS_WITH, …) carry CASE_INSENSITIVE by default, ~= / OPERATOR:CS / --case-sensitive match case (finding 11 is by design; the v1 route drops the option until finding 27, qqq#1358); TUI: operators by field type in the filter dialog; text matches ignore case (CASE_INSENSITIVE), Match exact case on request (live on fix branch 8bf6a5a66) |
| QRY-011 | Number operators including ranges and lists | pass | pass | BETWEEN, IN lists |
| QRY-012 | Date operators and relative date expressions | pass | pass | now(), now(-7d), thisPeriod(), startOf(); TUI: date presets: now(-7d), thisPeriod(month) … |
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | pass | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before; TUI: DATE_TIME criteria typed in the local zone go out as UTC instants; expressions pass through (unit-tested) |
| QRY-014 | Boolean, blob and long-text operators | pass | pass | IS_BLANK, TRUE, FALSE; TUI: TRUE/FALSE/EQUALS on booleans, blank tests, text operators on long text |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | pass | pass | labels in --where/--group for possible-value fields resolved to ids (speciesId = Cat sends 2; unknown or ambiguous labels exit 2 with the choices); verified live; TUI: labels in the picker, ids sent; typed labels resolved |
| QRY-016 | AND/OR and nested groups combine as declared | pass | pass | --group with parentheses; TUI: AND/OR and groups, guided and advanced |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | n/a | --print-filter gives the JSON the web links accept; TUI: URL state is browser-only; the TUI shows the grammar and the CLI command, and qqq ui accepts the command as a route |
| QRY-018 | Backend-only operators from links render and filter correctly | pass | pass | any operator in --where; TUI: More operators… offers every operator |
| QRY-019 | Quick search combines with the advanced filter | pass | pass | --search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter; sent with CASE_INSENSITIVE (--case-sensitive to match case; the v1 route drops the option until finding 27, qqq#1358); TUI: q quick search ANDed onto the filter (kernel); q avery finds Avery (CASE_INSENSITIVE); =Text matches exact case (live on fix branch 8bf6a5a66) |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | pass | pass | --join and table.field in --fields/--where/--order, labelled "Join: Field"; verified live through an association join (person to pet); exposed joins unit-tested (the sample exposes none); TUI: joined columns and filters, labelled "Join: Field", joins planned by the kernel (unit-tested only; the sample exposes no joins) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | pass | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct); TUI: many-side join: rows repeat and the status shows the distinct count (unit-tested only) |
| QRY-022 | Column statistics aggregate the filtered rows | pass | pass | qqq table stats <t> <field> [-w …] through the columnStats process when offered (unit-tested; the sample has neither QUERY_STATS nor the process); --local computes the same figures and was verified live; TUI: S: kernel column statistics of the column under the cursor with the filter (unit-tested only; no sample QUERY_STATS table) |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | pass | pass | without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in; TUI: the refusal says why |
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | pass | -r ids, --filter-json, --limit; TUI: Ctrl+A: this page, the full result, the first N, clear (unit-tested) |
| QRY-031 | Bulk Edit launches with the checked record ids | pass | pass | table bulk-edit -r ids; verified live; TUI: bulk edit on the checked records sends their ids; live: 2 person records edited and verified |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | pass | pass | table bulk-delete -w ... (or --filter-json, --all-records) sends the whole filter; verified live; TUI: bulk delete of the selected/full result sends the filter; live: 2 person records deleted |
| QRY-033 | First-N selection under a filter launches with that limit | pass | pass | process run --filter-json with limit; TUI: run a table process on the first N records of the result (filter with a limit; unit-tested only) |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | pass | pass | qqq table actions <t>: bulk load, bulk edit/delete and the table processes with the command for each; verified live; TUI: a lists bulk insert, bulk edit with a file, bulk edit, bulk delete, the template and the table processes, with each CLI command; live |
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | pass | pass | server refuses; exit 3; TUI: F-keys and entries hidden without permission; the server refuses |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | pass | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, -o/--out - (-o is the output file on every command); TUI: x: visible columns, filter and sort, file dialog |
| QRY-041 | XLSX and JSON exports carry the same rows | pass | pass | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
| QRY-042 | Nothing to export disables the formats | n/a | pass | nothing to disable; TUI: an empty result exports an empty file; the server decides |
| QRY-043 | Backend refuses exports of unreadable tables | pass | pass | server refuses; TUI: server refuses; the message is shown |
| QRY-050 | Your saved views are listed and open on their own route | pass | pass | qqq saved-view list <t>, open <t> <view>, table query --view; unit-tested and stub-server tested (the sample has no saved views); TUI: F9 › Saved views lists and opens views (kernel; unit-tested only) |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | pass | pass | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView; unit-tested and stub-server tested; TUI: save the current filter, sort, columns and page size as a view (unit-tested only) |
| QRY-052 | Update, rename and delete your view | pass | pass | saved-view save --replace, rename, delete (asks; --yes); unit-tested and stub-server tested; TUI: rename, update with the current query, delete (unit-tested only) |
| QRY-053 | Duplicate names and unknown views are reported without changes | pass | pass | a name in use is refused before any change; an unknown view exits 2 with the list; unit-tested and stub-server tested; TUI: server messages shown |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | pass | pass | views shared with you are listed as shared by their owner; changes to them are refused by the server, its message shown; unit-tested and stub-server tested; TUI: server refuses changes |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | pass | pass | --variant on query, count, get, copy, export, writes and process run; a terminal asks; the chosen variant is remembered per server and table (--variant - forgets it); stub-server tested (the sample has no variant table); TUI: the variant prompt before the first query; tableVariant on query and count (test) |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | pass | pass | no --variant, none remembered and no terminal (or no answer): exit 2, nothing queried; stub-server tested; TUI: dismissing queries nothing |
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | pass | describe shows capabilities; server refuses; TUI: F-keys hidden without permission |
| QRY-063 | Enum-backed Pet Species table is read-only | pass | pass | server refuses writes; TUI: no write F-keys without permission |
| QRY-064 | Backend refuses count and export for a table without those capabilities | pass | pass | TUI: server refuses; shown |
| QRY-065 | Export streams from the v1 export route | pass | pass | POST /table/{t}/export |
| QRY-066 | Variant options come from the v1 variants route | pass | pass | GET /table/{t}/variants (called live; the sample lists none; the listing is stub-server tested); TUI: v1 variants route (kernel) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | pass | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note; TUI: g |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | pass | default columns follow sections by tier |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | n/a | pass | locale formatting; TUI: counts with en-US separators |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | pass | shortcuts; TUI: n r f s and the F-keys; off with keyboard.singleKeyShortcuts |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | pass | pass | process run --variant (or the remembered one): tableVariant on init, every step and status; sent live, unit-tested (no variant table to observe); TUI: processes from a variant grid carry tableVariant through the runner (unit-tested only) |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | pass | quick filters UI; TUI: the filter dialog starts with the quick filter fields (defaultQuickFilterFieldNames, else T1); live on person |
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | pass | TUI: add a quick filter field; kept per table in the local view settings |
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | n/a | n/a | TUI: the guided builder shows every condition; no basic mode |
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | pass | TUI: guided builder edits and removes conditions |
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | pass | TUI: sort in the builder; c clears |
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | blocked | pass | weekday(field) IN 1,7 sends the WeekdayOfDate fieldFunction with ISO weekdays (--print-filter); the sample's H2 backend answers 500 (server finding 24, qqq#1355); TUI: weekday(birthDate) IN 1,7 returns its 2 people (live on fix branch 8bf6a5a66; on the released sample it fails, finding 24, qqq#1355) |
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | pass | expressions |
| QRY-087 | Bulk pasted values become a multi-value filter | pass | pass | IN lists; quoted values; TUI: IN takes a pasted comma list |
| QRY-088 | Instance query help accompanies the bulk paste dialog | n/a | pass | TUI: paste values split on commas, tabs, semicolons, new lines; the instance bulkAddFilterValues help shown (unit-tested only; the sample has no such help) |
| QRY-089 | String filter input honors field case behavior from v1 metadata | pass | pass | behaviors; TUI: behaviors applied to typed values |
| QRY-008 | Phone card list: readable records, touch-sized selection, query selections and paging | n/a | n/a | phone layout |
| QRY-009 | Phone filter sheet fits the viewport, scrolls inside and manages focus | n/a | n/a | phone layout |
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | n/a | n/a | phone layout |
| QRY-036 | Bulk actions launch from a phone card selection | n/a | n/a | phone layout |
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | n/a | n/a | phone layout |
| REL-001 | Possible-value cells show the related record label and link to it | pass | pass | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one; TUI: labels with ↗ links in the record view; l opens a grid cell's linked record; live |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | pass | table get --associations lists exactly the record's children; table children pages a list with its count (View All); TUI: child lists; F9 Query all opens the filtered list |
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | pass | table create <child> --parent table=pk; table get --associations shows it at once; TUI: F7 on a child list prefills the parent |
| REL-004 | Three-level associations: each pet shows only its own notes | pass | pass | person > pets > notes: each pet lists only its own notes; TUI: child lists three levels deep (a pet's notes under the pet); live on person 1 |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | pass | records are never cached: update or delete a child, the next get/children shows the current rows; TUI: the parent reloads on return |
| REL-006 | Denied child reads show an unavailable panel without data or actions | pass | pass | unreadable child tables shown as unavailable without data or actions; table children shows the server message; unit-tested (the sample has no denied child table, finding 17); TUI: unavailable panel (kernel associationGroups) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | pass | pass | aliased and composite associations bound to their widget sections, members by every join value; unit-tested (the sample declares none, finding 17); TUI: kernel associationGroups binds aliased associations to their widgets; adornmentLab tags through the composite association (live on fix branch 8bf6a5a66) |
| REL-008 | Adding through a composite association stores every join value | pass | pass | table create --parent sets every join value of a composite association; unit-tested (the sample declares none); TUI: a new child through a composite association gets every join value; a new adornmentLab tag gets adornmentLabId and labKey (live on fix branch 8bf6a5a66) |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | pass | table delete of a parent: its pets are gone, other parents keep theirs; TUI: server cascade; the list reloads |
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | pass | table get by section and tier; TUI: sections by tier, two columns when they fit |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | pass | pass | empty values show --; hidden sections and fields never shown; unit-tested (the sample has none, finding 17); TUI: hidden fields and sections are left out; empty values blank |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | pass | exit 5; TUI: error shown in the record pane |
| REC-004 | Record view tabs and list view expose every visible section | n/a | n/a | tabs; TUI: one scrolling view, no tabs |
| REC-005 | Create form validates required fields in the browser before any request | pass | pass | validated locally before any request; TUI: local validation by field |
| REC-006 | Create persists the entered values and opens the new record | pass | pass | TUI: new record replaces the form |
| REC-007 | Backend enforces required fields when the UI is bypassed | pass | pass | TUI: server message by field |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | n/a | pass | guard; TUI: Esc asks when there are unsaved changes; F10 warns too |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | pass | pass | update sends only changed fields; TUI: only changed fields sent |
| REC-010 | Edit shows non-editable fields read-only and never submits them | pass | pass | non-editable fields excluded; TUI: non-editable fields read-only |
| REC-011 | Edit of a missing record shows the load failure instead of a form | pass | pass | TUI: the load failure shows instead of the form |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | pass | confirm unless --yes; TUI: F8 asks; the grid moves on to the next record |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | pass | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped; TUI: c: copyRecordValues |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | pass | TUI: single-line text inline; multi-line through $EDITOR (Ctrl+E) round-trips |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | pass | |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | pass | defaults applied; TUI: Space toggles |
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | pass | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | pass | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested; TUI: local zone in and out (kernel datetime) |
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | pass | display zones; TUI: DATE_TIME through the kernel displayDateTime: server-zoned display values as sent, else the viewer's zone; live on Field Lab |
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | pass | n/a | HTML fields shown as text (sanitized); table update -e <field> edits the HTML source in $VISUAL/$EDITOR; verified live on fieldLab htmlValue; TUI: the rich-text editor is a browser component; HTML is edited as source in $EDITOR and shown as text |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | pass | server masks; TUI: masked |
| REC-022 | BLOB upload and download without a download adornment | pass | pass | table create/update --upload and table download (base64 BLOB value) verified on fieldLab; default name into the download directory; TUI: BLOB upload through the file dialog and save from the record (Field Lab, live); upload into uploadedFileArchive (text primary key typed on create) and download of the same 22 bytes (live on fix branch 8bf6a5a66) |
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | pass | pass | server; TUI: defaults applied by FormSession and the server |
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | pass | pass | maxLength checked locally; TUI: local check and server policy |
| REC-025 | Case and whitespace normalization on save | pass | pass | behaviors; TUI: behaviors on save |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | pass | pass | ranges checked locally; TUI: local range check and server policy |
| REC-027 | Unique keys are enforced with the backend message on create and edit | pass | pass | server message; TUI: server message shown |
| REC-028 | LINK adornment: record links and URL links | pass | pass | record links name table and key, --follow opens them; URL links show the URL; TUI: links marked, Enter follows (kernel record-view); adornmentLab carrier LINK (live on fix branch 8bf6a5a66) |
| REC-029 | CHIP adornment colors and icons by value | pass | pass | CHIP as [value] colored by color.<value> (or colorMap) on a terminal, icon named in the record view; unit-tested (none in the sample); TUI: CHIP values bracketed and colored by the adornment color; adornmentLab CHIP [ACTIVE] in the grid and the record (live on fix branch 8bf6a5a66) |
| REC-030 | SIZE adornment sets the query grid column width | n/a | pass | TUI: saved widths per column; SIZE as text width; adornmentLab SIZE (live on fix branch 8bf6a5a66) |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | pass | n/a | CODE_EDITOR values shown verbatim; table update -e <field> opens the code in $EDITOR with the language's extension; unit-tested (none in the sample; -e verified live on an HTML field); TUI: the code editor is a browser component; code is shown as text and edited in $EDITOR |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | pass | pass | RENDER_HTML shown as sanitized text; unit-tested (none in the sample); TUI: RENDER_HTML shown as text with the markup stripped (kernel htmlToText); adornmentLab RENDER_HTML as text in the grid and the record (live on fix branch 8bf6a5a66) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | pass | pass | REVEAL masked in the record view, query and child lists until --reveal; table get --copy <field> copies the value (pbcopy/clip/wl-copy/xclip, else OSC 52); masking unit-tested, --copy verified live; TUI: masked; Enter reveals; adornmentLab REVEAL masked in the grid and the record (live on fix branch 8bf6a5a66) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | pass | pass | FILE_DOWNLOAD shows the file name and qqq table download saves it through the field route; unit-tested (none in the sample); BLOB values without the adornment are decoded locally (server finding 16); TUI: Enter saves through the save dialog; adornmentLab FILE_DOWNLOAD (live on fix branch 8bf6a5a66) |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | pass | pass | create/update --upload field=path replaces the file, --set field= removes it (verified live on a BLOB); FILE_UPLOAD's format (button or drag and drop) is presentation only; TUI: FILE_UPLOAD fields choose a file; Backspace clears |
| REC-036 | TOOLTIP adornment shows its text on the value | pass | pass | tooltip text in parentheses after the value; unit-tested (none in the sample); TUI: tooltip text appended by valueText; adornmentLab TOOLTIP (live on fix branch 8bf6a5a66) |
| REC-037 | WIDGET adornment renders the field value as the named widget | pass | pass | the value rendered through the named widget's render model; unit-tested (none in the sample); TUI: a WIDGET-adorned field's value drawn as its widget; adornmentLab WIDGET field with a plain value shows the value (live on fix branch 8bf6a5a66) |
| REC-038 | ERROR adornment highlights the value | pass | pass | ERROR values marked with !; unit-tested (none in the sample); TUI: marked by valueText |
| REC-039 | Field help content follows screen roles and formats | pass | pass | table get --help-content: field help by view-screen role, HTML/MARKDOWN as text; qqq help show lists help alone; unit-tested (the sample has no help, finding 17); TUI: field help by screen role in F1 and in the form's field hint (unit-tested only) |
| REC-040 | Section help content on the record view | pass | pass | section help under the section with --help-content; unit-tested (finding 17); TUI: section help under the section heading and in F1 (unit-tested only) |
| REC-041 | Table help content slots are delivered in table metadata | pass | pass | table helpContent and helpContents slots printed with --help-content; unit-tested (finding 17); TUI: table help in F1 |
| REC-042 | Audit history lists who changed what, newest first | pass | pass | qqq audit list <t> <pk> [--asc]: newest first with field changes; node tests and a stub server (the sample audits no table); TUI: A: audits newest first, who, when, what, changes (unit-tested only; the sample has no audit source) |
| REC-043 | Audit history without process permission reads the audit table | pass | pass | without the audit process the audit table is read with its auditTable and auditDetail joins; node tests; TUI: without the audit process the kernel reads the audit table (kernel-tested; the TUI uses it) |
| REC-044 | Record label format is used for the record's title and dialogs | pass | pass | recordLabel |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | pass | labels resolved, defaults looked up; TUI: picker by keyboard |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | pass | pass | server; TUI: write keys hidden without permission; server refuses |
| REC-047 | Forms are labelled and keyboard operable | n/a | pass | TUI: keyboard only, labels on every field |
| REC-048 | Server validation errors show the backend message once and keep the form | pass | pass | fieldErrors by field; TUI: fieldErrors by field |
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | n/a | pass | TUI: breadcrumb uses the table label |
| REC-051 | Table-field possible values search and resolve through the v1 API | pass | pass | |
| REC-050 | Record create, view, edit and delete run on the v1 record routes | pass | pass | |
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | n/a | n/a | TUI: hash links |
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues= | n/a | pass | TUI: :table create T --set parent=… and F7 on a child list |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | pass | contingent fields; TUI: contingent fields wait and narrow |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | pass | pass | qqq table apis <t>, api-doc <t> <api> <version> (saved as JSON), api-call <t> <api> <version> <method> [path] (the playground: sends the request with your session, prints status and answer); none in the sample (verified: none listed), api-call stub-server tested; TUI: D on a grid: the table's APIs and their OpenAPI documents, view or save (unit-tested only) |
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script cre | pass | pass | qqq script list/show [--revision]/store --file/--editor/test -v input=…/logs, and table developer <t> <pk> (panels verified live on person 1); scripts and test runs stub-server tested (the sample has no associated scripts or testScript process); TUI: D on a record: panels (live), scripts, revisions, code, logs, store a new version (unit-tested) |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | pass | TUI: n e c d; F-keys |
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and | n/a | n/a | TUI: phone layout |
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-size | n/a | n/a | TUI: phone layout |
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON re | pass | pass | qqq table widget-form <t> <pk> <widget> --set/-i: the dynamicForm's fields validated (required) and saved in its merged JSON field (or as record fields); stub-server tested (the sample has no dynamicForm); TUI: record view: a dynamicForm widget section has "Edit these fields": FormEditor editors, required checked, saved into the named JSON field or record fields (unit-tested only) |
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and | pass | pass | inline enums; TUI: inline enums filter locally |
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | pass | pass | form adjusters (onLoad, onChange) and field behaviors run through the kernel FormSession for create, update and copy; unit-tested (the sample declares none); TUI: form adjusters run on change through FormSession (unit-tested only; the sample has none) |
| REC-064 | Record sections render qualified fields from readable exposed joins | pass | pass | table get --join adds joined fields (queryJoins); verified live through an association join; exposed-join sections unit-tested; TUI: section fields of readable exposed joins read through a joined query (unit-tested only) |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | blocked | pass | get, copy and update -i read with --variant; the v1 insert, update and delete routes ignore it (server finding 19, qqq#1351); TUI: records and forms opened from a variant grid read with tableVariant (unit-tested only; writes ignore it, finding 19) |
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | pass | an unchanged wall-clock time keeps the original instant and its precision; TUI: DATE_TIME edited in local zone, unchanged instants kept |
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | pass | TUI: a hidden table is not in the metadata: absent from the tree, palette and index; a direct :table query names no such table and loads nothing (unit-tested only) |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | pass | pass | a DISABLED table (permissions false in the metadata) is listed by table list with the reason; query, get, create, edit, delete, export and bulk commands refuse with it (exit 3) before any request; stub-server tested (the sample has no DISABLED table); TUI: a disabled table (hasPermission false) is listed with "(no access)"; opening it explains the denial and requests nothing (unit-tested only) |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | pass | TUI: F7 hidden without insert permission |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | pass | pass | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothi | pass | pass | |
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | pass | pass | table copy needs insert permission: refused with the reason (exit 3) before reading the record; stub-server tested; TUI: copy disabled with the reason without insert permission; c and :table copy are refused (unit-tested only) |
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, | pass | pass | TUI: a hidden process is not offered (tree, palette, actions) and :process run names no such process (unit-tested only) |
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses | pass | pass | a DISABLED process (hasPermission false) is listed by process list and table actions with the reason; process run refuses (exit 3) before init; stub-server tested; TUI: a disabled process is listed "(no access)" and refused with the reason; table actions show it disabled (unit-tested only) |
| SEC-009 | Widget restrictions: hidden widgets absent, disabled widgets show a permission state without loading data, bac | pass | pass | widget get shows the refusal; TUI: hidden widgets are absent; a disabled widget shows the permission state without a request; children of a parent skip denied ones (unit-tested only) |
| SEC-010 | App restrictions: hidden apps absent from navigation and unknown by link; disabled apps keep their children ga | pass | pass | TUI: hidden apps are absent and unknown by name; a disabled app explains the denial (unit-tested only) |
| SEC-011 | Record-level security (sharing demo): users see only their own or shared saved views/reports; direct links and | pass | pass | server-enforced: saved views (and bulk load profiles) are listed as the server returns them, yours first, others as shared by their owner; changing someone else's is refused locally or by the server; stub-server tested; TUI: saved views and reports: the TUI lists what the server returns; sharing through the share dialog; a refused direct link shows the server message; sharing demo: the owner sample:alice shares her saved report (live on fix branch 8bf6a5a66) |
| SEC-012 | Record security lock on a data table: list filtered to the key, other users' records unreachable, forged owner | pass | pass | server; TUI: record security is server-side: grids show the server's rows; a record the server refuses shows its message (403); adornmentLabLocker 2 answers Permission denied, shown on the record screen (live on fix branch 8bf6a5a66) |
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the m | pass | pass | server; TUI: hidden fields never shown (grid, record, form); PASSWORD values masked; edits send only changed fields, never the masked value (unit-tested only) |
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct | pass | pass | TUI: without TABLE_INSERT/UPDATE/DELETE capabilities the F-keys, menu entries and copy are off and n/e/d are refused (unit-tested only) |
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is w | pass | pass | TUI: a save refused mid-session keeps the form with what was typed and shows the server message (unit-tested only) |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | pass | pass | qqq login on a MOCK server; TUI: MOCK session, shared with the CLI keyring |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per- | pass | pass | qqq logout calls POST /logout; TUI: F9 Sign out |
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and m | pass | pass | exit 3 with the sign-in hint; TUI: a 401 mid-use opens sign-in on top of the screens; after signing in the same screen is back and refreshed (unit-tested only) |
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | pass | pass | TUI: a refused sign-in (TABLE_BASED 401) shows the reason, stores nothing, no anonymous fallback (unit-tested only) |
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | n/a | n/a | |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend | pass | pass | device flow and --browser (code + PKCE, loopback) verified against a sample on OAUTH2 with qqq-dev-idp; TUI: OAUTH2 sign-in in the browser: discovery, S256 PKCE authorize address shown, loopback callback, code exchanged with the verifier; device flow as the alternative (unit-tested only) |
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no s | pass | pass | denial, forged state and a refused code exchange exit 3 and save nothing; the token is checked against the server first; TUI: tampered state, provider denial and a refused code exchange are reported and store nothing (unit-tested only) |
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | pass | pass | logout --provider-logout: refresh token revoked (RFC 7009), end-session URL opened; bearer sign-ins have no server session; TUI: sign out: here, or also revoke the refresh token and open the provider end-session address (unit-tested only) |
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | pass | pass | a refresh the provider refuses exits 3 with sign in again; refresh before expiry verified; TUI: a 401 after the refresh opens sign-in (browser or device) on top and returns to the refreshed screen (unit-tested only) |
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT | blocked | pass | qqq login on an AUTH_0 server: tenant from values.baseUrl, values.nativeClientId (else clientId), the audience sent so the token is a JWT; device flow and --browser (PKCE) unit-tested; no AUTH_0 server to run against: the sample configures only OAUTH2 (server finding 28, qqq#1359); TUI: AUTH_0: authorize at baseUrl with the audience (PKCE), code exchanged at /oauth/token, the token handed to manageSession, the session kept; sign-out offers v2/logout (unit-tested only) |
| SEC-030 | AUTH_0 against a real Auth0 tenant | blocked | blocked | same sign-in as SEC-029; a real Auth0 tenant needs tenant credentials from James (qqq-cli#10; server finding 28, qqq#1359); TUI: needs a real Auth0 tenant: [qqq-cli#10](https://github.com/QRun-IO/qqq-cli/issues/10); the sample has no AUTH_0 configuration ([qqq#1359](https://github.com/QRun-IO/qqq/issues/1359), finding 28); the Auth0 protocol path is SEC-029 |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in a | pass | pass | |
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, | pass | pass | unknown type refused; TUI: unknown type refused with a message |
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session c | n/a | n/a | |
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSes | pass | pass | qqq login --username (password asked, or QQQ_PASSWORD): HTTP Basic once, the server's sessionId cookie kept, never the password; stub-server tested (the sample uses MOCK, so it cannot exercise TABLE_BASED; finding 8 is the missing manageSession recipe); TUI: TABLE_BASED: username then password (masked) under the app name, POST manageSession with Basic credentials, the session cookie kept, the password never stored (unit-tested only) |
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, | pass | pass | a wrong password or unknown user: one message (the server's), exit 3, nothing stored; stub-server tested; TUI: a wrong password or unknown user: one message, no session stored, Enter tries again (unit-tested only) |
| SEC-036 | TABLE_BASED session expiry: a session idle past the inactivity timeout gets a 401, the user signs in again and | pass | pass | an expired or idle session answers 401: exit 3 with 'Sign in again with qqq login'; stub-server tested; TUI: an expired session gets a 401: sign-in on top, then back to the refreshed screen (unit-tested only) |
| SEC-037 | TABLE_BASED logout: POST /qqq/v1/logout deletes the stored session and clears both cookies, protected pages st | pass | pass | qqq logout calls POST /qqq/v1/logout with the sessionId cookie and forgets the sign-in; later commands are unauthenticated; stub-server tested; TUI: sign out sends POST /logout with the session cookie, forgets the sign-in and shows sign-in (unit-tested only) |
| SEC-038 | Dashboard documents carry a strict Content-Security-Policy (script-src self plus the hash of each inline scrip | n/a | n/a | browser security |
| SEC-039 | The policy allows the configured identity provider origin (OAUTH2 discovery, AUTH_0 browser token exchange) an | n/a | n/a | browser security |
| SEC-040 | Application override hook and metadata-derived origins: the application adds its own origins (QApplicationJava | n/a | n/a | browser security |
| SEC-041 | The login page explains known sign-in error codes and never displays text taken from the link | n/a | n/a | browser security |
| SEC-042 | Rendered HTML (field values, widgets, process steps, help, banners) cannot add forms, form controls, style she | n/a | n/a | browser security |
| SEC-043 | Per-user browser data (recently viewed records, stored identity) is bound to the signed-in identity | n/a | n/a | browser security |
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arri | n/a | n/a | browser security |
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a ses | n/a | n/a | browser security |
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-exp | n/a | n/a | browser security |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | n/a | pass | browser accessibility and layout; TUI: keyboard only by design |
| INT-002 | Keyboard: run a process through its steps without a pointer | n/a | pass | browser accessibility and layout |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | n/a | pass | browser accessibility and layout; TUI: modal dialogs, Esc closes |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | n/a | pass | browser accessibility and layout |
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | n/a | n/a | browser accessibility and layout |
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce | n/a | n/a | browser accessibility and layout |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | pass | pass | field named in every message |
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal | n/a | n/a | browser accessibility and layout |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | n/a | pass | browser accessibility and layout; TUI: loading lines |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | pass | exit 4 and a message; rerun; TUI: F5 retries |
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | n/a | n/a | browser accessibility and layout |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | pass | no client cache of records; TUI: F5 and on return |
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | pass | (no records); TUI: no records explains it and offers F7 when the user may create; live |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | pass | cannot reach the server; TUI: sign-in screen shows the failure |
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bu | n/a | n/a | browser security |
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | pass | rendered as text |
| WID-002 | barChart widget: one bar per label in backend colors | pass | pass | rendered as text; TUI: bars in block characters |
| WID-003 | chart output from an untyped widget renders as a bar chart | pass | pass | rendered as text; TUI: a payload with chartData and no type is drawn as a bar chart (unit-tested only) |
| WID-004 | divider widget renders a rule without card chrome | pass | pass | rendered as text |
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | pass | pass | rendered as text; TUI: fieldValueList: labels, display values, prefix icon marks, indentation; empty state (unit-tested only) |
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | pass | pass | generic: its html as text, plus label, sublabel and footer; unit-tested (none in the sample); TUI: generic widget: label, sublabel, text from HTML, footer (unit-tested only) |
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | pass | pass | rendered as text; TUI: horizontal bars around a zero axis: negative values left, positive right; live structure in the fake dashboard (unit-tested only) |
| WID-008 | html widget (no-code) renders sanitized user HTML | pass | pass | rendered as text; TUI: html widget as text: tags and scripts dropped, links kept as text (unit-tested only) |
| WID-009 | lineChart widget: line with a point per label and description | pass | pass | rendered as text; TUI: sparkline and table |
| WID-010 | smallLineChart widget: compact line with title and HTML description | pass | pass | rendered as text; TUI: smallLineChart: title, HTML description as the subtitle, sparkline (unit-tested only) |
| WID-011 | location widget: image, title, description, address and footer | pass | pass | location: image link, title, description, address and footer text; unit-tested (none in the sample); TUI: location: image address, title, description, address, footer (unit-tested only) |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | pass | pass | multiStatistics: groups with icon, header and subheader, statisticList values with separators (links shown); verified live on SampleMultiStatisticsWidget |
| WID-013 | multiTable widget renders each table with its label and rows | pass | pass | rendered as text; TUI: multiTable: each table with its label and rows (unit-tested only) |
| WID-014 | pieChart widget: colored slices, legend values and subheader | pass | pass | rendered as text; TUI: percentage bars |
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | pass | n/a | prints the URL; TUI: embedding the QuickSight dashboard needs a browser frame; the TUI shows its address to open |
| WID-016 | statistics widget: count, context, colored percentage change and links | pass | pass | rendered as text |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | pass | pass | rendered as text; TUI: segments per dataset |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | pass | pass | rendered as text |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | pass | pass | rendered as text |
| WID-020 | usaMap widget plots each marker with name and coordinates | pass | pass | usaMap: each marker with name and coordinates as a table, 'No locations to show' when none; unit-tested; TUI: usaMap: each marker with name and coordinates in a table, the count; the map itself is browser-only (unit-tested only) |
| WID-021 | process widget runs the sample interactive process inline | pass | pass | a process widget names its process; widget get --run runs it here with the widget's default values; stub-server tested (no process widget in the sample); TUI: process widget: Enter runs its process in the process screen with the payload default values (unit-tested only) |
| WID-022 | parentWidget renders its children in a 12-column grid | pass | pass | parentWidget: children loaded and shown in order, each sized n/12 (gridColumns; TABS as tab headings); unit-tested; TUI: parent widget: children loaded with the parent's selections, stacked (tabs layout: one tab at a time, switch from the menu) (unit-tested only) |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | pass | pass | rendered as text; TUI: big numbers, up/down |
| WID-024 | childRecordList widget without association binding lists joined child records | pass | pass | rendered as text; TUI: childRecordList without an association: its rows and count from the payload (unit-tested only) |
| WID-025 | customComponent widget loads its bundle and renders the named component | n/a | n/a | customComponent is web-only |
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | pass | pass | cronUI: the record's cron expression, the payload's description and the time zone (widget get --record t=pk); 'No schedule set'; unit-tested; TUI: cronUI: the record's cron expression, the server description, the time zone; "No schedule set" (unit-tested only) |
| WID-027 | dynamicForm widget shows labeled values from the payload | pass | pass | dynamicForm: labeled values (display values first, or the merged JSON field); noFieldsMessage; unit-tested; TUI: dynamicForm: labeled values, required marks, the no-fields message (unit-tested only) |
| WID-028 | dataBagViewer widget lists versions and shows the selected version contents | pass | pass | dataBagViewer: the bag's versions newest first (current marked) and the current version's data as JSON; unit-tested; TUI: dataBagViewer: versions newest first (sequence, message, when, author) and the chosen version's contents pretty-printed (unit-tested only) |
| WID-029 | pivotTableSetup widget shows, edits and clears the saved report's pivot definition | pass | pass | qqq saved-report show prints the pivot definition (rows, columns, 'Sum of Total'); saved-report pivot sets it (--rows/--columns/--values FUNCTION:field) or --clear clears it; stub-server tested; TUI: pivotTableSetup: rows, columns, values shown; add rows/columns/values from the report columns; clear (unit-tested only) |
| WID-030 | filterAndColumnsSetup widget shows the saved report's filter, sort and columns | pass | pass | qqq saved-report show: filter (match all/any), sort and visible columns as the setup widget words them; stub-server tested; TUI: filterAndColumnsSetup: table, filter, sort and visible columns of the saved report (unit-tested only) |
| WID-070 | filterAndColumnsSetup edits a saved report's filter, sort, columns and variable values through the host form | pass | pass | qqq saved-report set: filter and sort (--where/--group/--order, ${variable.x} filter variables), visible columns (--columns), name; written to queryFilterJson/columnsJson as the setup does; stub-server tested; TUI: edit the report filter and sort in the TUI filter dialog and the columns in a chooser; saved to the record (unit-tested only) |
| WID-071 | a WIDGET-adorned process form field renders filter setup and writes its own value | pass | pass | a process field drawn by a filterAndColumnsSetup widget takes the filter grammar (-v field="a = 1 AND b > 2") and writes its QueryFilter JSON; stub-server tested; TUI: a WIDGET-adorned process field with a filter setup: Enter opens the filter dialog for the form's table and writes the filter JSON (unit-tested only) |
| WID-072 | API-versioned report setup loads table metadata and previews through the application API | pass | pass | qqq saved-report preview --api-path/--api-version queries and counts through the application API, as API-versioned setups preview; stub-server tested; TUI: API-versioned setup: fields from the application API metadata, preview rows and count through its query (unit-tested only) |
| WID-073 | report setup editor uses the full server-backed grid for paging, sorting and column sizing | pass | pass | qqq saved-report preview pages, sorts and counts on the server (-l, --skip, -o); columns fit their content (dragging widths is a browser grid control); stub-server tested; TUI: Preview opens the report filter in the full grid (server paging, sort, view settings) (unit-tested only) |
| WID-031 | rowBuilder widget without association binding shows its rows read-only | pass | pass | rowBuilder without association binding: its rows read-only in a table (declared fields, display values); unit-tested; TUI: rowBuilder without an association: rows read-only as a table (unit-tested only) |
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | pass | pass | scriptViewer: the script's revisions (current marked) and the current revision's files; unit-tested; TUI: scriptViewer: Enter opens the developer view of the script (revisions, files, logs) (unit-tested only) |
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | n/a | n/a | QuickSight; TUI: a real AWS QuickSight dashboard is an embedded browser page |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | pass | pass | apps show --widgets: app widgets in declared order, each marked with its width in twelfths (gridColumns); verified live; TUI: widgets in declared order, laid out side by side by gridColumns on wide terminals, v reorders; live |
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | pass | pass | chrome: a card widget's title framed (== label ==), plain (isCard=false) without; sublabel; payload or metadata footer HTML as text; unit-tested and verified live; TUI: label, sublabel, footer text or footer HTML as text; card vs plain is drawing only (unit-tested only) |
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | n/a | n/a | card chrome |
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | n/a | n/a | card chrome |
| WID-044 | widget help content (HTML) on the label | n/a | n/a | card chrome |
| WID-045 | reload button re-renders the widget from the backend | n/a | n/a | card chrome |
| WID-046 | export button downloads the widget's csvData as CSV | pass | pass | rendered as text; TUI: Export as CSV: the payload csvData (else the table rows) through the save dialog (unit-tested only) |
| WID-047 | PVS dropdown from the widget payload: selection is sent as a parameter and re-renders | pass | pass | --input name=value; TUI: payload dropdowns in the widget menu: the choice is sent as a parameter and the widget reloads (unit-tested only) |
| WID-048 | DATE_PICKER dropdown sends the chosen date | pass | pass | --input; TUI: DATE_PICKER dropdown: a date typed as YYYY-MM-DD is sent as the web sends it (M/D/YYYY) (unit-tested only) |
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | pass | pass | a widget waiting for a required dropdown shows the server's dropdownNeedsSelectedText in place of its body; selections given with --input name=id; unit-tested; TUI: required dropdowns: the server message shows until a choice is made, then the data loads (unit-tested only) |
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | n/a | pass | TUI: storeDropdownSelections: choices kept per widget in the shared config; a stale stored option is dropped (unit-tested only) |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | pass | pass | rendered as text; TUI: error contained to the widget |
| WID-052 | empty payloads show descriptive empty states for every display type | pass | pass | every display type shows the web's empty state (No rows found, No chart data available, No statistics available, …); unit-tested; TUI: empty states: no rows, no data to chart, no value, no values, no child widgets, no dashboard, no schedule (unit-tested only) |
| WID-053 | malformed payloads are contained to their own widget | pass | pass | unknown shapes shown raw |
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | pass | pass | rendered as text; TUI: a denied widget is drawn with the permission state and never requested (unit-tested only) |
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | n/a | n/a | card chrome |
| WID-056 | parent widget skips denied and unknown children without requests | pass | pass | parent widgets skip unknown and denied (hasPermission false) children without a request and say which; unit-tested; TUI: a parent widget skips denied and unknown children without requests (unit-tested only) |
| WID-057 | composite renders every block type with its values and styles | pass | pass | composite: TEXT (icons, alert/banner), NUMBER_ICON_BADGE, UP_OR_DOWN_NUMBER, TABLE_SUB_ROW_DETAIL_ROW, PROGRESS_BAR, DIVIDER, BIG_NUMBER, INPUT_FIELD, BUTTON, AUDIO, IMAGE, ICON, nested COMPOSITE, tooltips; unit-tested, BIG_NUMBER and UP_OR_DOWN verified live; TUI: composite blocks: TEXT, BIG_NUMBER, NUMBER_ICON_BADGE, UP_OR_DOWN_NUMBER, PROGRESS_BAR, DIVIDER, TABLE_SUB_ROW_DETAIL_ROW, INPUT_FIELD, BUTTON, IMAGE, AUDIO, ICON (unit-tested only) |
| WID-058 | composite layouts and slot links/tooltips | n/a | n/a | card chrome |
| WID-059 | unsupported block type shows a contained warning | pass | pass | rendered as text; TUI: an unsupported block type shows a contained warning (unit-tested only) |
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | pass | --input id=…; TUI: record section widgets get the record id and table (unit-tested) |
| WID-061 | process widget for a persona without process permission is contained | pass | pass | rendered as text; TUI: a process widget whose process the user may not run offers Run disabled with the reason (unit-tested only) |
| WID-062 | custom component whose bundle cannot load shows a contained error | n/a | n/a | card chrome |
| WID-063 | alerts with hideWidget or without HTML render nothing | pass | pass | TUI: an alert with hideWidget or without content draws nothing (unit-tested only) |
| RPT-001 | TABLE report runs through its process and downloads CSV matching the database | pass | pass | report run <r>: through the report's process (reportName, reportFormat), the file from GET /download saved (--out-dir); stub-server tested (the sample has no reports); TUI: a report with a process runs its screens and saves the file it produces (CSV chosen) (unit-tested only) |
| RPT-002 | TABLE report downloads XLSX with the same rows | pass | pass | report run --format xlsx: the same process, the XLSX file as the server writes it; stub-server tested; TUI: XLSX chosen from the report formats; the server's file saved as is (unit-tested only) |
| RPT-003 | TABLE report downloads JSON with the same rows | pass | pass | report run --format json: the same process, the JSON file; stub-server tested (format path shared with csv/xlsx); TUI: JSON chosen from the report formats; the server's file saved as is (unit-tested only) |
| RPT-004 | SUMMARY report groups rows with counts and a totals row | pass | pass | SUMMARY reports: the server's grouped rows with counts and totals saved as sent (process or report route); stub-server tested; TUI: SUMMARY reports: the grouped file with totals is the server's; the TUI runs and saves it (unit-tested only) |
| RPT-005 | PIVOT report produces a native pivot table in XLSX | pass | pass | PIVOT reports: the server writes the native XLSX pivot; the CLI saves the file unchanged; stub-server tested; TUI: PIVOT reports: the XLSX pivot is the server's; the TUI runs and saves it (unit-tested only) |
| RPT-006 | report input fields: required validation and the value filters the rows | pass | pass | report input fields asked on a terminal or given with -v; a missing required one exits 2 naming it before the report runs; the value is sent to the step; stub-server tested; TUI: report input fields are the report process's form: required checks, values sent with the run (unit-tested only) |
| RPT-007 | report permissions: restricted report denied in UI and by the server | pass | pass | server; TUI: a restricted report is marked (no access) and refused with the reason; the server refuses too (unit-tested only) |
| RPT-008 | reports appear in app navigation and open their run page | pass | pass | reports listed by report list and under their app in apps show; report run opens them; stub-server tested; TUI: reports appear in app navigation and run from there (unit-tested only) |
| RPT-009 | saved report view shows its filter/columns and pivot setup | pass | pass | qqq saved-report show: filter, sort, columns and pivot setup; stub-server tested; TUI: saved report record: filter, sort, columns and pivot setup drawn; saved report Pet Species Report: filter, sort, columns, pivot (live on fix branch 8bf6a5a66) |
| RPT-010 | saved report renders through renderSavedReport and downloads the saved columns and rows | pass | pass | qqq saved-report run <r> [--format]: renderSavedReport with the record, the file downloaded; stub-server tested; TUI: the record actions list the table processes (renderSavedReport) and run them on the record; the DOWNLOAD_FORM file is saved (process downloads unit-tested only) |
| RPT-011 | streaming report route for reports without a process | pass | pass | report run on a report without a process (or --route) streams from GET /reports/{name}; stub-server tested; TUI: a report without a process streams from GET /reports/{name}?format= into the save dialog (unit-tested only) |
| RPT-012 | scheduled report: create a schedule for a saved report and view its cron schedule | pass | pass | qqq saved-report schedule <r> --cron --time-zone --to [--format]; saved-report schedules lists them with the server's cron description; stub-server tested; TUI: scheduled report: created on its form; the record shows the cron schedule (cronUI) (unit-tested only) |
| RPT-013 | sharing: the owner shares a saved report with a user (Read-Only) | pass | pass | qqq share add savedReport <id> user:<id> --scope read-only (insertSharedRecord); stub-server tested; TUI: share a saved report with a user Read-Only (Z, :share); shared Pet Species Report Read-Only (live on fix branch 8bf6a5a66) |
| RPT-014 | sharing: change a share's scope to Read and Edit | pass | pass | qqq share scope savedReport <id> <share> read-write (editSharedRecord); stub-server tested; TUI: change a share to Read and Edit; changed to Read and Edit (live on fix branch 8bf6a5a66) |
| RPT-015 | sharing: remove a share | pass | pass | qqq share remove savedReport <id> <share> (asks; --yes); stub-server tested; TUI: remove a share after a confirm; removed the share (live on fix branch 8bf6a5a66) |
| RPT-016 | sharing record security: another user cannot see an unshared report until it is shared | pass | pass | server-enforced record security: saved-report list shows only what the server returns (yours, shared with you); stub-server tested; TUI: unshared reports are the server's to hide; the TUI lists what it returns and shows a refused link's message (unit-tested only) |
| RPT-017 | sharing: only the owner may share | pass | pass | only the owner may share: the server's refusal shown (exit 2); stub-server tested; TUI: only the owner may share: others see Share disabled with the reason (unit-tested only) |
| RPT-018 | sharing a saved view through the share dialog | pass | pass | qqq share … savedView <id>: the same sharing processes for saved views; stub-server tested with saved reports; TUI: saved views share through the same dialog (Saved views menu) (unit-tested only) |
| WID-064 | cronUI schedule editor on record forms: Basic days/hours/minutes, Advanced expression, live description, valid | n/a | n/a | card chrome |
| WID-065 | record view mounts only the active layout, so each section widget mounts and loads once | n/a | n/a | card chrome |
| WID-066 | Widget metadata and widget data come from the v1 API | pass | pass | TUI: widget metadata from /metaData and data from POST /qqq/v1/widget/{name} (unit-tested only) |
| WID-067 | a widget whose payload is a single leaf block (Material block widget) renders that block | pass | pass | a payload that is one leaf block renders that block; unit-tested; TUI: a payload that is one block draws that block (unit-tested only) |
| WID-068 | table widget Material extras: typed cells (numbers with separators, html, htmlAndTooltip, image, composite), h | pass | pass | table extras: hidden columns dropped, numbers with separators, html, htmlAndTooltip, image cells, composite cells, sub-rows, sticky last (totals) row, link and noRowsFoundHTML; unit-tested, sticky row verified live on SampleTableWidget; TUI: table cells typed: numbers with separators, html and htmlAndTooltip as text, images, composites (unit-tested only) |
| WID-069 | chart widget Material extras: theme color names, legend toggles, single-series legends (horizontal bars, line | n/a | n/a | theme colors |
| RPT-019 | render report input step shows only its fields; a report without variables adds no no-fields message | pass | pass | the render step shows only its fields (kernel screen model); a step without fields shows no 'no fields' message; unit-tested; TUI: a report input step shows only its fields; a step without fields continues with no message (unit-tested only) |
| RPT-020 | Report files download from the v1 download and report routes | pass | pass | report files come from GET /download (process reports) or GET /reports/{name} (route); stub-server tested; TUI: report files download from the report route and the process DOWNLOAD_FORM (unit-tested only) |
| WID-074 | Standalone composite scalar controls and metadata editors | n/a | pass | standalone composite controls are browser input editors (caret, IME, widths) whose drafts stay on the page; the CLI shows the controls and their values (WID-057), and process-hosted inputs go through the form session (PRC-037); TUI: standalone composite INPUT_FIELD blocks show their seeded values read-only by type (no standalone input target in a terminal) (unit-tested only) |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | pass | pass | a seeded DATE_TIME input block is shown in the local zone with its seconds (kernel displayDateTime); unit-tested; TUI: standalone DATE_TIME blocks show seeded instants in the local zone with seconds (unit-tested only) |
| HLP-001 | App and widget help | pass | pass | qqq help show app/widget/instance; unit-tested (the sample carries no help content, finding 17); TUI: F1 on an app home: app help and each widget's help (slot maps) (unit-tested only) |
| HLP-002 | Table, field and section help | pass | pass | qqq help show table <t> [field]: table, section and field help; unit-tested (finding 17); TUI: table, field and section help by screen role in F1 (unit-tested only; the sample has none); F1 on adornmentLab: table and section help (live on fix branch 8bf6a5a66) |
| HLP-003 | Query columns and table action help | pass | pass | field help is the column help; table action help slots printed with their slot name; unit-tested (finding 17); TUI: the grid footer shows the help of the column under the cursor; table help slots in F1 (unit-tested only) |
| HLP-004 | Process, step and record action help | pass | pass | qqq help show process <p>: process, step, HELP_TEXT and step field help; unit-tested (finding 17); TUI: F1 on a process: process, step and step field help; on a record: table, section and field help for the view role (unit-tested only) |
| HLP-005 | Help search | pass | pass | qqq help-search; TUI: type in F1 |
| HLP-006 | Help author affordance | n/a | pass | author affordance; TUI: :helpkeys (F9 Help) turns on help-authoring mode: F1 shows every slot key, with or without content (unit-tested only) |
+466
View File
@@ -0,0 +1,466 @@
# Next UI feature acceptance matrix
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](https://github.com/QRun-IO/qqq-frontend-next/blob/main/docs/ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the
acceptance gate results. Each row is a supported QQQ UI feature or use case with executable
real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.
Rows: 394. Required rows passing in the recorded runs: 391.
Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-full-chromium/gate.json` (2026-10-01T01:03:05.420Z), `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-ci-chromium/gate.json` (2026-10-01T01:23:18.171Z).
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](https://github.com/QRun-IO/qqq-frontend-next/blob/main/docs/acceptance/esb.md). The historical full-suite counts above predate those ten new matrix rows.
## Navigation, application structure, branding and icons
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| NAV-001 | Direct link to a table list renders seeded rows | success | | prime-test-database.sql | pass (chromium) | |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under their parent | admin sees Miscellaneous, Nav Empty App, Nav Level One, People App, Sample Widgets Dashboard, Sharing Demo in backend order; Greetings App appears only under People App (regression: it was duplicated at top level); group children use metadata labels | noPets persona: Pet and Pet Note are absent from the sidebar; backend returns 404 for their metadata and 403 for a query | stock sample apps + NavigationFixtures (Nav Level One, Nav Empty App); persona noPets | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | expand three nested groups and open Nav Deep Item; grid rows match nav_deep_item; deep link to /app/navDeepItem expands all three groups and marks the item current | | NavigationFixtures: navLevelOne > navLevelTwo > navLevelThree > navDeepItem | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | Dashboard is current on /app; click app, table, process; URL, content and aria-current follow; collapse hides children, expand shows them | | stock sample (Miscellaneous app) | pass (chromium) | |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | sidebar glyph per declared Material name, color applied, image path loads; app home entries and section headings show declared icons; command palette entries show declared icons | no declared icon renders the type fallback glyph | sample icons (stars, person, widgets, share, pets, local_shipping, badge) + NavigationFixtures (path icon, colored icon, inventory_2, inbox, layers) | pass (chromium) | [#538](https://github.com/QRun-IO/qqq/issues/538) |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access show not-found and the backend denies them | sidebar, app home, landing page and command palette omit City, Greet People, Simple Sleep and Nav Hidden Note | /app/city and /app/greet show the not-found state; backend returns 404 and 403 | sample city (hidden table), greet and simpleSleep (hidden processes); NavigationFixtures navHiddenNote (hidden, NOT_PROTECTED) | pass (chromium) | |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | /app/navHiddenNote lists nav_hidden_note rows; breadcrumbs show its enclosing apps | | NavigationFixtures navHiddenNote (isHidden, PermissionLevel.NOT_PROTECTED) | pass (chromium) | |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each entry navigates | Miscellaneous: Actions Sleep Interactive, Simple Throw; Data Carrier, Field Lab, Pet Species (labels, not names); section heading shows its label and declared icon; clicking an entry opens the table or process | hidden or unpermitted section entries are omitted (navHiddenNote, noPets persona) | sample greetingsApp and miscellaneous sections | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-009 | App home table entries show the backend record count | each Data entry reads 'N total records' equal to the SQL count | | sample carrier, fieldLab, petSpecies, person, pet, petNote rows | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | People App shows Clone People and the Greetings App child app (regression: crashed on missing widgets); Nav Level One lists its child app and navigates to it; Nav Empty App shows the empty state | viewer persona: People App shows only the Greetings App child app (no denied process) | sample peopleApp; NavigationFixtures navLevelOne, navEmptyApp; persona viewer | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared widget | sidebar click opens the dashboard; one widget region per declared widget label, in order | | sample SampleWidgetsDashboard (11 widgets) | pass (chromium) | |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding accepted without warnings | sidebar shows /samples-logo.png with alt QQQ Sample, loaded, linking to the dashboard; favicon link points to /kr-icon.png; primary color variables equal the branding accent color; no 'schema validation' console warning for branding without companyName/companyUrl | | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 | pass (chromium) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized HTML, severity and colors | each slot shows its message in the right place with severity and colors from metadata | unknown slots are not rendered | NavigationFixtures banners: QFMD_TOP_OF_SITE (INFO text), QFMD_TOP_OF_BODY (WARNING HTML), QFMD_SIDE_NAV_UNDER_LOGO (SUCCESS, colors, additional styles) | pass (chromium) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb navigates | /app/person: People App / Greetings App / Person; /app/navDeepItem/2/edit: Nav Level One / Nav Level Two / Nav Level Three / Nav Deep Item / 2 / Edit; process and create pages; clicking a crumb opens that app home | no breadcrumbs on the dashboard root | sample person/greetInteractive; NavigationFixtures navDeepItem | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | Dashboard \| QQQ Sample; Person \| Greetings App \| People App \| QQQ Sample; record label title on the record page | unknown route title is Not Found \| QQQ Sample | stock sample | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-016 | The site root redirects to the dashboard | / replaces itself with /app (back does not return to /) | | stock sample | pass (chromium) | |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata by label | counts equal the navigable tables, processes, apps and their widgets from backend metadata; quick actions create only navigable insertable tables and run only navigable processes, by label (regression: listed internal and table-bulk processes, crashed on apps without widgets); application cards open app homes | viewer persona: no create or process quick actions | stock sample + NavigationFixtures; persona viewer | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | app home, nested app home, table, process, record view, record edit, create, saved view | | stock sample + NavigationFixtures | pass (chromium) | |
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | refresh on a table with a query string, a record view, and an app home | | stock sample | pass (chromium) | |
| NAV-020 | Browser back and forward traverse navigation history with the right content | dashboard > app home > table > record, then back three times and forward twice | | stock sample | pass (chromium) | |
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | /app/noSuchThing shows 'Page not found' naming the segment; the link opens the dashboard; no failed requests | | stock sample | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | /app/person/1/no/such and /no-such-page respond 404 and render the 404 page; Go Home reaches the dashboard | only the document 404 is reported | stock sample | pass (chromium) | |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | ?code=NAV-B2 opens record 2 (Tide Chart); ?email=... opens that person | no match, several matches (?shelf=North), unknown field, no parameters: error message, no navigation | NavigationFixtures navDeepItem (unique key code); sample person emails | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context, filters, and navigates by keyboard | filter 'pet' shows Pet, Pet Note, Pet Species with type and app; Enter opens the first; '.' opens the palette; Escape closes it | hidden objects are not listed | stock sample + NavigationFixtures | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by label (matched locally, alongside record search when the backend offers it: NAV-030) | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh; search dialog contains Tab and Shift+Tab with empty or populated results; Clear returns to the input; Escape and backdrop dismissal restore keyboard and pointer openers | no search request fails (regression: every keystroke produced a 404 from a missing /search endpoint); with no searchable tables no request is made at all (NAV-032) | stock sample | pass (chromium) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | hamburger opens the drawer; choosing a table navigates and closes it; close button and a backdrop tap close it; drawer controls are 44 px touch targets | | stock sample, 412x839 touch viewport | pass (chromium) | |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | metadata counts equal the backend's; framework shows the bundled Next.js version (regression: hardcoded 'Next.js 15') | | stock sample | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report page | sidebar entry with its icon opens /app/navDeepItemReport with the report heading, title and breadcrumbs; app home Reports group lists and opens it | | NavigationFixtures navDeepItemReport (child of Nav Level Three, icon assessment) | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | sidebar shows only Dashboard; landing shows You do not have permission to access any apps. | app home links show the not-found state | harness persona noApps (every permission except App HAS_ACCESS) - requested: not yet in AcceptanceSampleServer | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/search over each table's searchFields) finds, after the matching pages, by record label with the table label; results open by keyboard and the search page survives refresh | '/' dialog: 'COCO' finds the pet Coco (label and table label), case-insensitively; ArrowDown and Enter open /app/pet/{id}; the request searches the readable searchable tables (person, pet, savedView) with a per-table limit of 5; header box: 'Sample' lists the matching people (database order, limit 5) after pages; Enter opens /app/search?q=Sample whose Records region lists every match (limit 25) before and after refresh; a link opens the record | a one-character term is not sent (unit), and a recent record already found is not listed twice (unit) | stock sample + NavigationFixtures (search fields on person, pet, savedView) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | noPets: 'ey' lists the matching people only; the UI never asks for pets and the metadata advertises no pet search fields; admin: the backend returns the pets matching 'EY' for tableNames [pet]; alice finds her saved view 'Alice People View' with the saved view table label, in the UI and from the API; bob, with whom the view is shared read-only, finds it from the API (the lock admits shared readers) | noPets: POST /qqq/v1/search with tableNames [pet] returns no results; an unscoped search returns no pets; casey (neither owner nor shared reader): the locked saved view is not found in the UI or from the API | stock sample + NavigationFixtures + QueryFixtures (alice's saved view shared read-only with bob); noPets persona; sharing demo users | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a search endpoint | the variant's v1 metadata lists no table with searchFields; header box, '/' dialog and search page keep the local labels and matches, with no Records group or region | no request to /qqq/v1/search or /search is made while typing, opening the dialog or loading the search page | security variant (stock sample + SecurityFixtures) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authentication (no session), never the banners | unauthenticated authentication metadata carries exactly appName, logo, icon and accentColor; after logout and a fresh load the login card shows the loaded logo and QQQ Sample; primary color variables and the Sign in button use the accent; favicon /kr-icon.png; title Sign in \| QQQ Sample | no banner text in the unauthenticated response or on the login page | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 and three banners | pass (chromium) | [#703](https://github.com/QRun-IO/qqq/issues/703) [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{table}/{id}/{process} runs it for that record and returns to it | /app/person/person.bulkEdit?recordsParam=recordIds&recordIds=1,2 replaces itself with the run (init sends 1,2; Back returns to the table); /app/person/prcQuickTask opens the instance process with returnTo the table; /app/person/1/clonePeople runs for record 1 (init and SQL clone) and Return lands on the record; /app/person/2/bulkEdit resolves the table process by suffix; Cancel returns to the record | /app/person/1/noSuchProcess shows the in-shell not-found state and starts no process; report URLs (/app/{table}/{report}) are not covered: the backend frontend report metadata (QFrontendReportMetaData) has no tableName, so no report can match (#732) | sample person.bulkEdit, clonePeople (table processes), prcQuickTask (ProcessesFixtures, instance process) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) [#732](https://github.com/QRun-IO/qqq/issues/732) |
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | record Actions > Clone People runs for the record and Return lands on the record; Bulk Edit from /app/person?filter=isEmployed: returnTo is the launching path and query; after the edit (SQL) Return restores the same filter and the filtered rows | the unmatched record (Morgan) stays out of the returned grid | sample person, clonePeople, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScreen turned off hide the home-screen title and the table counts | v1 metaData carries supplementalAppMetaData.materialDashboard for the app; The app home has no visible title (a screen-reader-only h1 remains) and the table entry shows no count; No count request is made for the table | Apps without the settings keep their title and counts (Material defaults) | NavigationFixtures.java navQuietHome app (navQuietItem table) with MaterialDashboardAppMetaData(false, false) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Escape, the close button or a chosen link close it and return focus to the menu button | Enter on the menu button opens the Navigation dialog with focus inside; Tab and Shift+Tab visit every drawer control and never reach the page behind; Escape, the close button and choosing Carrier close it; focus returns to the menu button; Carrier opens | Tab never leaves the open drawer (QRun-IO/qqq#708: focus stayed on the menu button; Firefox could not reach the drawer) | stock sample (Miscellaneous app), 412x839 touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes and not-found links are 44 px touch targets, and the page never scrolls sideways | skip link is the first Tab stop and 44 px tall when shown; header, breadcrumbs, sidebar or drawer entries and the user menu meet the 44 px target; no sideways page scroll with a four-level breadcrumb trail | under a mouse (pointer: fine) the desktop sizes are unchanged | stock sample + NavigationFixtures (navDeepItem three apps deep); mobile (Pixel 7) and tablet (iPad, desktop layout with touch) projects | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views and query controls | Material data-qqq-id and class selectors identify corresponding Next UI controls; application CSS keyed to those selectors changes the rendered style; record and query actions retain the expected behavior and button variants | | stock sample with NavigationFixtures and RecordsFixtures; application CSS applied in css-hooks.spec.ts | pass (chromium) | [QRun-IO/qqq-frontend-next#731](https://github.com/QRun-IO/qqq-frontend-next/issues/731) |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the branded header | v1 metaData publishes the selected Material theme settings; the rendered dashboard applies its primary token, branded header colors and tagline, and custom CSS; Every theme scenario resets instance metadata before navigation; Preferences exposes accessible Light and Dark choices; keyboard changes, real reload persistence, reset and light/dark dialog accessibility are verified; Application theme override is explained in Preferences without erasing the saved browser appearance choice; Pivot validation meets text contrast in default light, default dark and a configured orange error theme; API playground visible text and hovered controls meet contrast in light/dark themes; Preferences changes update the rendered palette without reload | | AcceptanceSampleServer /acceptance/theme; reset removes the theme before each test | pass (chromium) | [QRun-IO/qqq-frontend-next#719](https://github.com/QRun-IO/qqq-frontend-next/issues/719) |
## Performance budgets on the production export: large tables, heavy dashboards and long processes (QRun-IO/qqq#710). Bundle budgets are checked separately by scripts/perf-budget.mjs.
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | all 40 columns have a sort header; the first 25 rows carry the exact computed values; the total reads 10,000; time from navigation to the first page is within PERFORMANCE_BUDGET.firstPageMs | only one page of rows is in the DOM (25 rows, 1,000 cells), not the table | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | page size 250 shows ids 10000..9751 with 10,000 cells within PERFORMANCE_BUDGET.pageMs; next page shows 9750..9501 and last page 250..1 (page 40), each within PERFORMANCE_BUDGET.pageMs; the backend is asked for skip/limit 250 | the DOM never holds more than one page (250 rows) | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | sorting by Name ascending shows Row 00001..Row 00025 within PERFORMANCE_BUDGET.queryMs; Count 04 equals 0 narrows to the 40 matching rows (the SQL answer) within PERFORMANCE_BUDGET.queryMs | | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budget | every widget finishes loading within PERFORMANCE_BUDGET.dashboardMs of navigation; statistics show their exact counts, chart data tables their exact points, tables their 100 rows | each widget's data is requested exactly once | tests/acceptance/fixture/PerformanceFixtures.java prfDashboard | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result promptly | progress messages advance while the job runs; the result shows Row Count 10000 and the SQL total; the result appears within PERFORMANCE_BUDGET.processResultMs of the job finishing | status is polled no more than once per POLL_INITIAL_MILLIS (1.5 s) and never concurrently | tests/acceptance/fixture/PerformanceFixtures.java prfLongRun | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
## Processes: steps, components, lifecycle, bulk load/edit/delete
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list results render | recordIds 1,2 -> setup prefix/suffix -> results view values and both greeted rows | | sample greetInteractive | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#656](https://github.com/QRun-IO/qqq/issues/656) |
| PRC-002 | All-records run from a filter selection with no criteria | filterJSON {criteria:[]} greets every seeded person (count checked against SQL) | | sample greetInteractive | pass (chromium) | |
| PRC-003 | Zero-record runs from a filter that matches nothing | clonePeople input 0, validation on 0 with no lines, 0 processed | greetInteractive record-list step with zero records is refused ("Missing input records.") | sample greetInteractive, clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-004 | Processes launched from an app (no records) | app shortcut opens Component Lab without records | greetInteractive without records is refused at init ("Missing input records.") | prcLab app, sample greetInteractive | pass (chromium) | |
| PRC-005 | Process launched from selected rows of a table query | select Avery and Casey, Actions -> Greet Interactive -> both greeted | | sample person + greetInteractive | pass (chromium) | |
| PRC-006 | Minimum and maximum input records | 1-2 selected records run and are listed | 0 or 3 explicit records show a message without init; filter selecting 5 shows the backend refusal; API filter selecting 0 is refused | prcBounds (min 1, max 2) | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | HELP_TEXT, HELP_TEXT, VIEW_FORM, EDIT_FORM, HTML, EDIT_FORM in order | | prcComponents mixed screen | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | plain lines; Show/Hide preview text | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | Lab Inputs section holds only its fields; values saved (SQL prc_lab_run) | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-010 | Required input validation on screens and in the backend | filled required field proceeds | empty required field blocks without a request; API step without the required input is refused | prcComponents | pass (chromium) | |
| PRC-011 | Process possible-value fields | options from the process route; chosen id saved, label shown on review; search filters options | | prcComponents labColor (prcColor enum) | pass (chromium) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-012 | VIEW_FORM labels and backend values | Introduction and Status values | | prcComponents | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-013 | HTML component renders the step html value, sanitized | markup rendered | script and onerror removed, never executed | prcComponents mixed.html | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-014 | DOWNLOAD_FORM delivers the generated file | download bytes equal the generated file | unregistered file path refused (403) | prcComponents compute step | pass (chromium) | |
| PRC-015 | Step help content for process screens | help text shown | | prcComponents helpContent (PROCESS_SCREEN) | pass (chromium) | |
| PRC-016 | Back to the backend back step with entered values | Back restarts at the back step with values (possible-value label shown); resubmitting saves again | no Back without a backend backStep | prcComponents review, prcWizard longDetails, greetInteractive | pass (chromium) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | short route drops a screen from the stepper; values logged (SQL) | | prcWizard | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | field relabelled and required on the later screen | empty relabelled required field blocks | prcWizard long route | pass (chromium) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | confirm dialog; cancel step row (SQL) from a screen and while working | Stay on Page keeps the run | prcProgress cancelStep | pass (chromium) | |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; expanded details meet contrast requirements in light, dark and application themes; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium) | |
| PRC-022 | Retry after an error restarts with the original record selection | retry loads the selected records 2 and 4 | | prcFlaky (fails once) | pass (chromium) | [#658](https://github.com/QRun-IO/qqq/issues/658) |
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | Submit on the step before the last screen; Return only on the last screen; noMoreSteps ends early | | prcComponents, prcWizard, prcEarlyFinish | pass (chromium) | |
| PRC-024 | Linear stepper and step position | all step labels; current step marked; Step i of n | | prcComponents | pass (chromium) | |
| PRC-025 | RECORD_LIST paging and page size over process records | 1-10, 11-20, 21-23; page size 25 | next disabled on the last page | prcManyRows (23 generated records) | pass (chromium) | |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | Input: 2 Person records.; radio switches the button label; preview 1 of 2 / 2 of 2 | | sample clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | four lines with counts and messages; link filters to the refused record | nothing persisted by validation | sample clonePeople + inserted clone persons | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-028 | Process summary results after execution and persisted effects | result lines and processed count; clones persisted (SQL); skip validation executes immediately | refused and nested records not cloned | sample clonePeople | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-029 | Bulk edit: switched-on fields only, review and result | edited fields persisted for selected records only (SQL); result lines | no switched-on field blocks without a request | enricher person.bulkEdit | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-030 | Bulk delete: review, validation and result | selected records removed (SQL) | validation deletes nothing | enricher person.bulkDelete | pass (chromium) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | uploaded CSV mapped and inserted (SQL) | missing required file blocks | enricher person.bulkInsert + prcUploadArchive | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) [#543](https://github.com/QRun-IO/qqq/issues/543) |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | Back from review keeps the mapping; Back from mapping to upload | unmapped required column blocks without a request | enricher person.bulkInsert | pass (chromium) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-033 | Bulk edit with a file matched by key fields | rows matched by Id updated (SQL) | | enricher person.bulkEditWithFile | pass (chromium) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-034 | Upload instructions HTML with downloadable template | template CSV downloads with the header row | | enricher person.bulkInsert upload.html | pass (chromium) | |
| PRC-035 | Named WIDGET fetched with the process UUID and values | widget text built from process values | | prcWidgets prcHtmlWidget | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-036 | Named composite WIDGET seeded from process values | seeded blocks rendered, no fetch | | prcWidgets prcCompositeWidget | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | Approve submits actionCode (SQL); Enter submits the scan code (SQL) | conditional block hidden | prcWidgets ad hoc blocks | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | help text and edit form submit (SQL); picker explains it is not configured | required export note blocks | prcDrive | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-039 | Google Drive folder selection through the Google Picker | pick a folder and submit its id | | needs GOOGLE_APP_CLIENT_ID, GOOGLE_APP_API_KEY and a Google account | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): Google Drive folder picker needs a real Google OAuth client and account; the rest of the step is covered by PRC-038; real-service testing is follow-up work.) | |
| PRC-040 | Reload mid-process starts a fresh run | new process UUID, first screen, empty inputs; earlier effects kept (SQL) | | prcComponents | pass (chromium) | |
| PRC-041 | Expired session mid-process returns to sign-in | 401 redirects to login | nothing saved | prcComponents + expired persona | pass (chromium) | [#668](https://github.com/QRun-IO/qqq/issues/668) |
| PRC-042 | Process permission enforcement | revoked permission shows the permission message | step refused, nothing saved; direct link offers no process; API init refused (403) | prcComponents + noProcesses persona | pass (chromium) | |
| PRC-043 | Default process values from the link (defaultProcessValues) | sleepMillis preset drives an async sleep to screen1; labName prefilled; normal navigation shows a required sleep duration with a 1000ms default and completes to screen1 | clearing sleep duration blocks submission on screen0 | sample sleepInteractive, prcComponents | pass (chromium) | |
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | auto-focused input submits on Enter (SQL) | no Next/Cancel buttons rendered | prcScanner (format scanner) | pass (chromium) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | screen repeats with fresh inputs and counted rounds; noMoreSteps ends with Return | no stepper or Step i of n | prcLoop (STATE_MACHINE) | pass (chromium) | |
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | Save As stores the mapping (SQL); choosing the profile restores the mapping and is named on review; Delete removes it (SQL) | a duplicate name is refused | SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium) | [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-047 | A run with no frontend screens completes with a completion screen | completion heading and message; backend effect persisted (SQL) | | prcQuickTask (backend step only) | pass (chromium) | |
| PRC-048 | Process-field possible values load and search through the v1 API | the process combobox loads its options from the v1 process route; the chosen value reaches the process and persists; a v1 search returns the exact match | an unknown process field is 404 | prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | a bulk load uploads its file, maps, reviews, steps back (isStepBack=true) and inserts through /qqq/v1/processes/... and /qqq/v1/metaData/process/...; the inserted row is in the database; this session can cancel its own run | an unsafe upload filename is 400 before anything is stored; an unknown run is refused; (another session's run is refused 403 by records and cancel - backend unit tests) | person.bulkInsert, prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) [#543](https://github.com/QRun-IO/qqq/issues/543) |
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScreens) | v1 /metaData names the process in supplementalInstanceMetaData.materialDashboard (the only supplemental entry v1 publishes; the unversioned route's javalin server configuration stays off v1); casey receives it with no table and in no app (API); the person query Actions menu lists it last, after a divider, and runs it on the selected rows with tableName=person (SQL); the record actions menu of qryBin (no processes of its own) runs it on that record (SQL); the phone record action sheet runs it on that record (SQL) | a user whose metadata omits it (alice): not offered on query or record screens and its run page is not found (no SQL rows); noProcesses persona: absent from the metadata, not offered on query or record screens, and the backend refuses it (403, no SQL rows); a user whose metadata omits the process gets an empty v1 processNamesToAddToAllQueryAndViewScreens list (no hidden process names); the v1 and unversioned init routes refuse the process for a user without permission | ProcessesFixtures.java prcTagRecords (no table, in no app) named by MaterialDashboardInstanceMetaData; a metadata customizer lists it only for the sample user casey, so other areas keep their menus; person, qryBin | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | every search posts the screen values as the values map of the v1 JSON body; choices narrow to the chosen Category and change when it changes; the chosen id is recorded (SQL) and its label shows on the result screen | Category empty: the unset variable makes no matches for a form, shown as No options found; the backend refuses an out-of-filter id lookup and returns only the filtered choices | prcSpecimenPick specimenId (prcSpecimen source filtered on category = ${input.category}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, save as a copy, delete, unsaved-change count with its list, Reset All Changes, New Bulk Load Profile, Reset to Empty or Suggested Mapping | a new profile is named in the Save New Bulk Load Profile dialog and Enter saves it (SQL row owned by the user); the heading names it; a change shows 1 Unsaved Change with the list of changes; Save asks Update Existing Bulk Load Profile and Enter confirms; the same row holds the new mapping (SQL); Rename keeps the row id under the new name; Save As stores a separate copy, which is then in use; a saved profile's changes are listed (header row, file column) and Reset All Changes restores its mapping; New Bulk Load Profile leaves the profile for an empty mapping; Reset to Suggested Mapping and Empty Mapping replace the mapping, and the suggested one is what the review screen shows | Enter in the delete dialog does not delete (SQL count unchanged); only the Delete button does, and the screen starts a new, empty mapping | enricher person.bulkInsert; SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a profile | the query process returns both profiles with their owners; the menu lists alice's under Your Saved Bulk Load Profiles and bob's under Bulk Load Profiles Shared with you; on bob's profile Save, Rename and Delete are disabled with the not-owner reason, the changes list says why, and no Save link is offered; Save As is enabled; alice saves her own copy (SQL row owned by sample:alice) | bob's row is unchanged (label, owner and mapping, SQL) and no store or delete request named it; the backend has no ownership lock on saved bulk load profiles, so the UI is the only guard, as in Material | sample users alice and bob; bob's profile stored through storeSavedBulkLoadProfile and shared READ_ONLY with alice in shared_saved_bulk_load_profile | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle re-mapping by header name, scroll to the first error, bulk-edit Key Fields / Fields To Update and the unmapped key field error | help for the header row, layout and table key fields shows under each and describes its control; a mapped column's tooltip names its fields; a repeated header is flagged and not offered as a column; without a header row columns are letters; a mapping to the repeated header is cleared with a warning when headers come back; off and on again the step posts header names (First Name, Last Name, Email) and the first Email column is inserted (SQL); an unmappable submit brings the first field error into view; bulk edit shows Key Fields (Select table key fields to continue.) and Fields To Update (Clear if empty only there); choosing Id names it as an unmapped key field until a column is chosen; the matched records update (SQL) | no step request is sent while a field has no column | enricher person.bulkInsert and person.bulkEditWithFile; ProcessesFixtures.primeBulkLoadHelp help content keyed process:person.bulkInsert;field:hasHeaderRow\|layout and process:person.bulkEditWithFile;field:tableKeyFields | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material QHierarchyAutoComplete) | the Person default value is chosen by searching person possible values and shows its label; Birth Date takes a date; Is Employed takes Yes or No; the Add Fields menu groups Pet and Pet Note, searches, disables mapped fields with a tooltip and adds the WIDE note field twice (Pet Note: Note (1) and (2)); species values are mapped by searching the species possible values; the pets, their person, species and birth date and both notes each are inserted (SQL) | a default value left empty is refused with A value is required. before any step request | enricher pet.bulkInsert (pet with its notes association, WIDE layout) and person.bulkInsert | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | Choose a labeled possible value, edit currency and multiline code, update a widget filter without changing columns, upload BLOB and FILE_UPLOAD data and verify backend-read content; Desktop wheel scrolling over a plain numeric block preserves its value; intentional keyboard edits and touch input changes are returned by the backend process review | Required uploads block submission; code-editor Enter does not submit the process | prcBlockEditors | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | Plain and metadata editors receive configured focus, placeholders and previous process values; Enter saves trimmed text verified in SQL; ordinary result screens receive heading focus | First blur does not show premature validation; required whitespace cannot advance either input screen | prcInputFocus | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | Unchanged seeded timestamps preserve seconds and instant across plain/shared blocks and regular form fields, including the repeated autumn DST hour; Edited timestamps crossing daylight saving submit UTC values verified from backend SQL; TIME inputs in plain/shared blocks and forms retain seconds for unchanged and edited submissions, verified through SQL readback. | Zoned values must not disappear from datetime-local controls or lose their timezone on submission | prcInputDateTimes with browser timezone America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-065 | Configured Google Drive shared-folder selection and process submission | Select a shared-drive folder and submit ID, name, token and other required input through the real process; independently verify database outcome | Reject a file and retry; cancel a replacement selection clears the destination and token; missing required note cannot advance | prcDrive with owned Google SDK script responses and real configured backend CSP | pass (chromium) | [#704](https://github.com/QRun-IO/qqq/issues/704) |
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | Backend uppercase/lowercase metadata transforms a middle-of-value edit without moving the caret; Enter and Submit persist exact values verified through independent SQL; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Submit persists the exact transformed text through independent SQL; Unicode case expansion preserves continued typing in STRING and TEXT fields; submitted values are verified through independent SQL; Explicitly configured PASSWORD case behaviors preserve masking and show/hide values; Enter and Submit persist Unicode-expanded values verified through independent SQL | Typing alone does not submit or persist the process; Multiline Enter does not submit or navigate away | prcInputCase / prcTextCase | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
## Querying, relationships, saved views, export and bulk selection
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | Sort matches SQL; Pin right is reflected in the grid; Filter opens the field editor; Hide removes the column | | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-091 | Column menu copies page or full query values in backend sort order | Page copy contains the first ten ids; Full copy contains all eleven ids | | sample carrier (11 rows) | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | Search narrows fields; Groups collapse and expand; Hiding a field marks the columns dirty; Keyboard opening focuses the popup and Tab reaches search; Explicit close and Escape restore Columns focus; outside click keeps the clicked control focused | Unknown search shows an empty message | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | Dirty filter survives reload; Table route opens the last saved view; Reset restores the backend view; Displayed filter updates the URL before a delayed page navigation completes; immediate reload preserves it, along with unrelated parameters, hash and history length | | sample Alice People View | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-094 | Counted quick views render and open their saved-view route | Count reflects the backend filter; Quick-view button opens the filtered view | | sample Alice People View marked as a quick view in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-095 | A new query defaults to fifty rows per page | Fifty visible rows; Pagination reports fifty of the full count | | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-096 | A saved view reconciles removed fields and warns the user | Obsolete criteria and sort are removed before querying; Warning names the removed field; Heading names the active saved view | | sample Alice People View with retiredField references in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | Headers use field labels; Rows equal SQL select ordered by id desc; Possible-value cells show labels, booleans Yes/No, money/decimals formatted; Original Next uniform row backgrounds in light and dark mode, with opaque pinned cells and selection highlighting | Null boolean renders empty, not No; Hidden and heavy (TEXT) fields are not columns | QueryFixtures.java qryItem (8 rows, every field type) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-002 | Pagination with Material page sizes and URL state | Page 2 shows the remaining rows; Page sizes 10/25/50/100/250; Page and size survive reload via URL | Next is disabled on the last page | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | Ascending and descending match SQL order by; Sort persists across reload | Third click returns to the PK-descending default | QueryFixtures.java qryItem | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-004 | Column visibility and order persist per table | Hide/show and reorder columns; Order and visibility survive reload; Show/hide all per table group | Another table keeps its own columns | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-005 | Column widths, density and card view | Resized width persists; Density changes row height; Card view shows the same records | Keyboard resize separator exposes its value | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | Filter, sort and page are restored after Back; Refresh shows a row changed through the API | No stale cached rows after refresh | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-007 | A failed query reports the backend error with a retry | Backend error text shown; Retry reloads after the failure clears; Query error text and the front error notification meet text contrast in light, dark and configured application themes; Retry repeats the backend request; hover remains readable; Dismiss hides the error; resetting the invalid filter restores SQL-verified records | No rows from the failed request are shown | QueryFixtures.java qryItem | pass (chromium) | |
| QRY-010 | Text operators match Material labels and backend semantics | equals, does not equal, contains, starts/ends with, is any of, is like, is empty/not empty; results equal SQL | does not equal includes empty values (NOT_EQUALS_OR_IS_NULL); Incomplete criteria are not sent | QueryFixtures.java qryItem.name/code | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-011 | Number operators including ranges and lists | equals, greater/less (or equal), is between, is any of; results equal SQL; Values sent as numbers | not between and none of exclude the bounds/list | QueryFixtures.java qryItem.quantity/price | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-012 | Date operators and relative date expressions | is/after/before/between with dates; today, N days ago, start of this/last month send backend expression JSON and match SQL | Clearing an expression restores a plain value input | QueryFixtures.java qryItem.receivedDate (relative to today) | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-013 | Date-time operators convert local input to UTC and support relative values | Local input sent as UTC instant; now and N hours ago expressions match SQL | Grid shows formatted date-times, not raw ISO | QueryFixtures.java qryItem.checkedAt | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#679](https://github.com/QRun-IO/qqq/issues/679) |
| QRY-014 | Boolean, blob and long-text operators | equals yes / equals no / is empty for booleans; blob is empty / not empty; long text contains | Blob offers no value operators | QueryFixtures.java qryItem.isActive/photo/notes | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | is / is any of / is none of with label chips; Link-loaded ids render labels; Results equal SQL | Search with no match shows no options | QueryFixtures.java qryItem.ownerId (person), speciesId (petSpecies enum) | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| QRY-016 | AND/OR and nested groups combine as declared | AND group, OR group and nested subfilter match SQL | Removing the last criterion clears the filter | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | Reload keeps the filter; Back/forward switches filters; Plain JSON filter link opens filtered | Malformed filter param falls back to no filter | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-018 | Backend-only operators from links render and filter correctly | Each operator from a link renders its label and returns the SQL rows | FALSE matches no records; builder does not crash | QueryFixtures.java qryItem | pass (chromium) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-019 | Quick search combines with the advanced filter | Search term ANDs with Is Active = yes | Clearing search restores the filtered rows | QueryFixtures.java qryItem | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | Join column 'Owner: First Name' hidden by default, shown on demand; Join filter and sort match SQL LEFT JOIN | Joins only sent when used; rows without a match are kept | QueryFixtures.java qryItem -> person (MANY_TO_ONE) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | Repeated rows per note; Count shows (N distinct) | Row selection dedupes repeated ids | QueryFixtures.java qryItem -> qryItemNote (ONE_TO_MANY) | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-022 | Column statistics aggregate the filtered rows | Value distribution and count/distinct/sum/avg/min/max equal SQL under the filter | Sort by value/count | QueryFixtures.java qryItem (QUERY_STATS) | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | No statistics button on headers | Process not launched | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-030 | Selection menu: this page, full query result, first N, clear | Banners and counts for page, all and subset; Touch opens and toggles the density menu without synthetic trigger focus dismissing it; Enter, Escape and outside search focus retain their normal behavior; Pinned selection targets remain exposed and respond at both edges for every density | Subset dialog rejects invalid N | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-031 | Bulk Edit launches with the checked record ids | recordIds param equals checked rows | No selection shows 'No records were selected to Bulk Edit.' | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | filterJSON has no skip/limit; backend recordCount equals SQL count | Current page paging is not sent | sample (person, pet, petNote, carrier, petSpecies) carrier (page 2) | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-033 | First-N selection under a filter launches with that limit | filterJSON limit N with the filter and sort | N larger than the result is capped | QueryFixtures.java qryItem | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | Bulk Load/Edit/Edit With File/Delete then table processes; Launch carries the selection | Process min/max record checks block launch with a message | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#662](https://github.com/QRun-IO/qqq/issues/662) |
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | No Create, bulk or write process entries | Direct insert/delete API calls return 403 | viewer persona on sample (person, pet, petNote, carrier, petSpecies) | pass (chromium) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | File name, headers and rows equal SQL; Join columns exported | Hidden columns excluded | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-041 | XLSX and JSON exports carry the same rows | XLSX sheet and JSON array equal SQL rows | JSON keys limited to exported fields | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-042 | Nothing to export disables the formats | Empty filter result disables CSV/XLSX/JSON | No export request sent | QueryFixtures.java qryItem | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-043 | Backend refuses exports of unreadable tables | Export of a readable table succeeds | Export of pet returns 403 without data | noPets persona | pass (chromium) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-050 | Your saved views are listed and open on their own route | 'Your Saved Views' lists backend views; /savedView/{id} applies filter/columns | Empty group message | sample sharing demo (Alice People View) | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | saved_view row viewJson matches Material RecordQueryView | Unsaved-changes indicator clears after save | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-052 | Update, rename and delete your view | Update keeps unrelated view JSON; Rename and delete change the saved_view row | Reset All Changes restores the stored view | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-053 | Duplicate names and unknown views are reported without changes | Duplicate name error shown in the dialog | Unknown view id shows an error; saved_view unchanged | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | Listed under 'Views Shared with you' for bob; Opens with its filter | Owner-only actions disabled; storeSavedView by bob refused | QueryFixtures.java (Alice People View shared with sample:bob READ_ONLY) | pass (chromium) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | Picker lists /variants; Rows equal the chosen store; Choice remembered and changeable; export uses it | Filter narrows options | QueryFixtures.java qryStock on qryVariantMemory (qryStore) | pass (chromium) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | Prompt shown with a choose button | No query sent; API query without a variant fails | QueryFixtures.java qryStock | pass (chromium) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-062 | A table without count, export or write capabilities offers none of them | Rows listed without a total; Export disabled with explanation; No create or bulk actions | Backend insert refused | QueryFixtures.java qryLedger (no TABLE_INSERT/UPDATE/DELETE/EXPORT/COUNT) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-063 | Enum-backed Pet Species table is read-only | Metadata omits write capabilities; no Create or bulk writes | Insert returns 403, not 500 | sample (person, pet, petNote, carrier, petSpecies) petSpecies | pass (chromium) | [#692](https://github.com/QRun-IO/qqq/issues/692) |
| QRY-064 | Backend refuses count and export for a table without those capabilities | Query still succeeds | v1 count, legacy count and export return 403 | QueryFixtures.java qryLedger | pass (chromium) | [#693](https://github.com/QRun-IO/qqq/issues/693) |
| QRY-065 | Export streams from the v1 export route | the export menu posts {format, filename, fieldNames, filter} to /qqq/v1/table/{table}/export and downloads every row; the v1 route streams the same rows as CSV | a table without the export capability is refused (403) by the v1 route | qryItem, qryLedger | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| QRY-066 | Variant options come from the v1 variants route | the variant picker lists the stores from GET /qqq/v1/table/{table}/variants; the v1 route lists every option with its type and id; a table without variants lists none | an unknown table is refused (403) | qryStock (qryStore options) | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | v1 table metadata carries supplementalMetaData.materialDashboard.gotoFieldNames (API); Go To... on the query toolbar and the record view header offers the primary key, then each key, labelled by field labels; primary key, single-field and two-field keys each open the one matching record (EQUALS query, id from SQL); no match shows "Record not found."; a partial key matching two bins shows "More than 1 record was found..." (counts from SQL); Enter in an input submits its option; Escape closes and returns focus to the Go To button; a table with GET but no QUERY capability opens Go To automatically and it cannot be dismissed | a table without gotoFieldNames has no Go To button on its query or record screen; no navigation on no match or several matches | QueryFixtures.java qryBin (gotoFieldNames [[code], [aisle, shelf]]), qryLocker (GET without QUERY), qryItem (no Go To) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order (Material bySection order) | Headers read Id, Name, Code, Price, Quantity; Rows equal SQL select ordered by id desc | The metadata field-map order (price first) is not used | QueryFixtures.java qryOrdered (qry_item rows; fields declared price, quantity, code, id, name; sections identity [id, name, code], details [price], stock [quantity]) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | Page 5 at 250 per page reads Showing 1,001–1,234 of 1,234; The page shows exactly the last 234 ids by SQL | No unformatted range such as 1001–1234 | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | r sends a new query request and shows a value changed through the API (SQL confirms the change); f opens the Advanced Filters panel and a second f keeps it open; n opens the create form | keys typed in quick search are text (no filter panel, no navigation); viewer persona: n does nothing (f still works); the backend refuses the insert (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and return to the query | Bulk Delete of the South store's full query result counts its one record; Every init and step request carries tableVariant {type: qryStore, id: 2}; Return comes back to the variant query, which is empty for South while North keeps its two rows | Without the variant the backend could not read the variant table's records | QueryFixtures.java qryStock (memory backend keyed by qryStore variants) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | First Name quick filter finds the SQL-matching records; Type-specific defaults gate Boolean until chosen, default possible values to any-of and date-time to after; A multi-value chip summarizes extra values; A saved Basic view restores its criterion and SQL rows | No application-specific field names are embedded in the UI; Cancel discards an un-applied quick-filter draft | sample person and QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | A field offered by metadata appears as a chip after selection and reload; Joined and virtual fields query SQL-backed records | Field choice is scoped to this table | sample person and QueryFixtures.java qryItem/qryCaseItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | Repeated Name conditions disable Basic with a focusable reason tooltip; URL criteria become chips; OR criteria open Advanced with a reason | Clicking Basic does not discard either condition | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | Name and Quantity conditions filter SQL rows; deleting Name in the inline editor preserves Quantity | Removing one condition does not reset the whole query | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | Name ascending order matches SQL before and after confirmed clear; Condition status explains missing and complete values on keyboard focus | Cancel leaves conditions in place | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | Selecting a weekday sends WeekdayOfDate and returns the SQL-matching rows; Date-time weekday sends WeekdayOfDateTime with configured UTC arguments and returns matching rows | The weekday list uses names rather than bare numbers | QueryFixtures.java qryItem with dated rows and Material weekday settings; AcceptanceSampleServer.java H2 date functions | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-086 | Relative date presets expose their evaluated date and filter records | Seven days ago renders a live date and returns the recent SQL-backed rows; A date-time preset retains its relative expression, evaluates on focus and returns recent rows | A relative value remains an expression instead of fixed text | QueryFixtures.java qryItem with relative dates | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-087 | Bulk pasted values become a multi-value filter | Two pasted record names return the matching SQL-backed rows | The dialog previews each value before adding it | QueryFixtures.java qryItem | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-088 | Instance query help accompanies the bulk paste dialog | The dialog displays the configured help text | Help text comes from instance metadata | QueryFixtures.java instance-level QUERY_SCREEN help | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-089 | String filter input honors field case behavior from v1 metadata | Uppercase typing is normalized and queries the lowercase stored code | The normalized value remains visible in the input | QueryFixtures.java qryCaseItem code with TO_LOWER_CASE | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
| QRY-008 | Phone card list: readable records, touch-sized selection, query selections and paging | One card per record with the SQL ids and names, values wrap instead of being cut off; Tapping the 44 px label around a card checkbox selects it without opening the record; A full-query selection checks every card; paging and rows per page work on cards; Tapping a card opens its record | No horizontal page scroll and no control below 44 x 44 px | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-009 | Phone filter sheet fits the viewport, scrolls inside and manages focus | Filter opens a modal sheet that takes focus; Many conditions scroll inside the sheet, which stays inside the viewport; Cards behind the sheet show the filtered records (SQL); Escape or Close closes the sheet and returns focus to Filter, which counts the conditions | No horizontal page scroll and no control below 44 x 44 px in the sheet | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | The panel opens inside the viewport with touch-sized controls; Hiding a column removes it from the cards (phone) or the grid (tablet) and persists; Escape closes the panel and returns focus to its button | The panel never runs off the left edge of a phone | QueryFixtures.java qryItem | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-036 | Bulk actions launch from a phone card selection | Checked cards show the selection banner; The Actions menu fits the phone; Bulk Edit launches with exactly the checked ids | No horizontal page scroll while the menu is open | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | The saved views menu lists your view inside the viewport; Escape closes it; The export menu fits the phone and downloads a CSV | No horizontal page scroll and no control below 44 x 44 px in either menu | sample sharing demo (Alice People View), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REL-001 | Possible-value cells show the related record label and link to it | Owner cell shows the person label; Link opens /app/person/{id} | Enum values are not links | sample (person, pet, petNote, carrier, petSpecies) pet.personId | pass (chromium) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | Panel rows equal SQL children; View All filters /app/pet and Back returns | Parent without children shows an empty panel | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| REL-003 | Adding a child from its parent links it and shows it immediately | New pet stored with person_id of the parent; Panel refreshes | Join field not editable; no other rows changed | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | |
| REL-004 | Three-level associations: each pet shows only its own notes | Notes equal SQL per pet | Other pets' notes absent | sample (person, pet, petNote, carrier, petSpecies) person -> pet -> petNote | pass (chromium) | |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | Renamed pet shown in parent panel and list; Deleting a pet removes its notes | Other rows unchanged | sample (person, pet, petNote, carrier, petSpecies) pet, petNote | pass (chromium) | |
| REL-006 | Denied child reads show an unavailable panel without data or actions | Panel says related records are unavailable | No data, View All or Create; API query 403 | noPets persona; qryShelter whose associated qryShelterPet is denied with DenyBehavior.DISABLED | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | childRecordList and rowBuilder show SQL-matching members; Composite join on code + review date | Household without members shows empty | QueryFixtures.java qryHousehold/qryMember (members, care group / primary, scheduled reviews) | pass (chromium) | |
| REL-008 | Adding through a composite association stores every join value | New member stored with household_code and review_date from the parent | Aliased association unaffected; other rows unchanged | QueryFixtures.java qryHousehold/qryMember | pass (chromium) | |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | Person and its pets removed | Other pets and notes unchanged; list reflects SQL | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium) | |
## Records, fields, editors, adornments, validation and persistence
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | label heading; T1 header fields; T2 section cards; currency, grouped integer, Yes/No, date | values match independent SQL readback | prime-test-database.sql (person 1) | pass (chromium) | |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | NULL date shows an em dash; hidden field absent from view and from the GET payload; hidden section not rendered | hidden value stays stored (SQL) | person 4 (NULL birth date); recordLab hiddenCode + hidden legacy section | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | not-found panel; back to the table list | 404 read is the only failed request; no generic error toast | person 999 (absent) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-004 | Record view tabs and list view expose every visible section | Overview shows all T2 sections; Overview field rows retain original Next typography and spacing in light and dark themes; a section tab shows only that section; list view shows every section; Arrow/Home/End keys move tab focus; Enter/Space activates a named panel with one tab stop; phone accordion supports keyboard activation | | fieldLab record created in the test | pass (chromium) | |
| REC-005 | Create form validates required fields in the browser before any request | required markers and messages for each required field | no insert request, no row | person | pass (chromium) | |
| REC-006 | Create persists the entered values and opens the new record | success toast; navigates to /app/person/<new id>; view shows the entered values | non-editable fields are not offered on create; row matches SQL readback | person | pass (chromium) | |
| REC-007 | Backend enforces required fields when the UI is bypassed | direct insert without Name is rejected with the backend message | no row written | fieldLab | pass (chromium) | |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | clean cancel returns immediately; dirty cancel asks Stay/Leave; Stay keeps typed values; Leave discards | no insert or update request; no row created or changed | person | pass (chromium) | |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | fields prefilled from the record; Save disabled until a change; view shows the new value without reload | only the changed field is submitted; other columns unchanged in SQL | person 1 | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-010 | Edit shows non-editable fields read-only and never submits them | read-only controls with the stored values | controls are disabled; create date unchanged in SQL after save | person 1 (id, createDate, modifyDate) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) [#537](https://github.com/QRun-IO/qqq/issues/537) |
| REC-011 | Edit of a missing record shows the load failure instead of a form | error message, no form fields | 404 is the only failed request | person 999 | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | dialog names the record; Cancel keeps it; Delete removes it and returns to the list; success toast | no Resource not found toast; no 404 read of the deleted record; row gone in SQL | person 5 | pass (chromium) | [#541](https://github.com/QRun-IO/qqq/issues/541) |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | source values prefilled without the key; Full Copy loads the descendant draft on selection; returning to Base Copy preserves the base form; save creates a new row and opens it; source unchanged | unique-key conflict on copy shows the backend message and keeps the form; download-URL files are not copied | person 1; recordLab 1 | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | single-line input; multi-line textarea keeps line breaks in view and SQL | | fieldLab | pass (chromium) | |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | LONG beyond 2^53 saved and shown exactly; DECIMAL with 4 places | fractional LONG rejected in the browser; backend rejects a fractional LONG sent directly | fieldLab | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | create form starts at the default (true); switching to false persists false; view shows Yes/No | unchanged boolean is not rewritten on edit | fieldLab (booleanValue default true) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-017 | DATE and TIME editors round-trip values and clearing | date input value and view; time input value and backend 12-hour display; clearing a date stores NULL | | fieldLab | pass (chromium) | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | typed local time stored as the UTC instant; view shows yyyy-MM-dd hh:mm:ss AM EDT; edit form shows the local time | unchanged date-times are not rewritten; non-editable date-times read-only | fieldLab; browser zone America/New_York | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | fixed America/Chicago display; display in the record's own timeZone; daylight-saving boundary | invalid zone falls back to UTC | fieldLab | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | formatted text rendered on the view | script content is not executed | fieldLab htmlValue | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | view shows the mask; typing a new password saves it | editing another field keeps the stored password (SQL) | fieldLab passwordValue | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-022 | BLOB upload and download without a download adornment | file chosen on create is stored byte-for-byte; view offers the bytes as a download | | fieldLab blobValue | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | user id and dates filled on create; modify day updated on edit, create day kept | NONE leaves the value empty | fieldLab | pass (chromium) | |
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | truncate, ellipsis and pass-through stored as declared | reject shows the backend message, keeps the form and writes nothing | fieldLab (maxLength 8) | pass (chromium) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-025 | Case and whitespace normalization on save | upper, lower, trim variants, remove-all-whitespace and unchanged stored as declared | | fieldLab | pass (chromium) | |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | clip to 0/100 and 0.01/99.99 | out-of-range and exclusive-boundary values show the backend message and write nothing | fieldLab | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-027 | Unique keys are enforced with the backend message on create and edit | distinct values save | duplicate Name on create; duplicate normalized key on edit; form keeps values, row unchanged | fieldLab (name, normalizedKey) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-028 | LINK adornment: record links and URL links | toRecordFromTable opens the referenced record; URL opens in a new tab (target _blank) | a non-http value is not turned into a link | recordLab (ownerId, website) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-029 | CHIP adornment colors and icons by value | label is the display value; color from color.<value>; icon from icon.<value> | undeclared value uses the default color | recordLab status | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-030 | SIZE adornment sets the query grid column width | column is 100px wide | undeclared columns keep the default width | recordLab shortCode (width small) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | view shows the code and its language; Format JSON / Reset Format; editor saves edited code | invalid JSON reports a formatting error | recordLab config (json) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | markup rendered | script removed and not executed | recordLab htmlNote | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | masked by default; reveal shows the exact stored value; hide masks again | | recordLab apiToken (PASSWORD + REVEAL) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | file name from the companion field; file name from the format; open and download return the stored bytes | record without a file shows no link | recordLab attachment (heavy, fileNameField) and notesFile (fileNameFormat) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | drag-and-drop zone and button formats; current file shown on edit; new file replaces bytes and file name | removing the file clears bytes and name | recordLab attachment (dragAndDrop), notesFile (button) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-036 | TOOLTIP adornment shows its text on the value | tooltip on hover and keyboard focus | | recordLab hint (staticText) | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#685](https://github.com/QRun-IO/qqq/issues/685) |
| REC-037 | WIDGET adornment renders the field value as the named widget | widget content from the record | the value is not offered as an editable field | recordLab summaryWidget | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-038 | ERROR adornment highlights the value | value shown with error styling | | recordLab problem | pass (chromium) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-039 | Field help content follows screen roles and formats | view: READ_SCREENS text and VIEW_SCREEN HTML; edit: WRITE_SCREENS markdown rendered and EDIT_SCREEN text; create: INSERT_SCREEN text | help for other screens is not shown | recordLab title/website/ownerId help | pass (chromium) | [#681](https://github.com/QRun-IO/qqq/issues/681) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-040 | Section help content on the record view | HTML help under the section heading | | recordLab presentation section | pass (chromium) | [#681](https://github.com/QRun-IO/qqq/issues/681) |
| REC-041 | Table help content slots are delivered in table metadata | v1 table metadata carries the slot help | | recordLab summary slot | pass (chromium) | |
| REC-042 | Audit history lists who changed what, newest first | edit then open Audit: message, user and per-field sentences; audit rows independently verified in SQL | unaudited record shows no audits | recordLab (FIELD audits) | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-043 | Audit history without process permission reads the audit table | same entries via the audit table query | no process request is made | recordLab; persona noProcesses | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-044 | Record label format is used for the record's title and dialogs | heading; delete dialog; audit title | | recordLab (Lab: %s) | pass (chromium) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | edit shows the stored label, not the id; create default shows its label; keyboard search and select saves the id | | recordLab status/ownerId | pass (chromium) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | no Edit, Copy or Delete; create and edit URLs show the permission message | direct insert, update and delete are 403 and change nothing | persona viewer | pass (chromium) | |
| REC-047 | Forms are labelled and keyboard operable | every control has a label; keyboard-only create saves the record | invalid fields expose aria-invalid and a described error | person | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-048 | Server validation errors show the backend message once and keep the form | alert and toast show the backend message; typed values remain | no generic Something went wrong toast | fieldLab | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | create page title is "Create Scheduled Report \| Scheduled Report \| QQQ Sample"; record view title is the record label, then Scheduled Report | the table name (scheduledReport) is not shown in the breadcrumbs | scheduledReport (memory, not in the app tree) for savedReport 1 | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
| REC-051 | Table-field possible values search and resolve through the v1 API | owner search sends a v1 POST with the search term and lists the exact match; ids and labels resolve to the exact option; the chosen owner persists | a source table the persona cannot read is refused (403) by the v1 route; an unknown field is 404 | recordLab | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-050 | Record create, view, edit and delete run on the v1 record routes | the create/edit/delete flow sends POST, GET, PATCH and DELETE to /qqq/v1/table/... and persists each step; record-v1 associations are written and read back with includeAssociations | a missing required field is a 400 with the backend message and nothing is written; a read-only persona is refused (403) insert, update and delete on v1 and the row is unchanged | person, fieldLab, qryHousehold | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | #audit opens the audit dialog; closing it clears the hash and a reload does not reopen it; #/launchProcess=person.bulkEdit replaces the URL with the run for the record (init recordIds=2); Cancel returns to the record; #employmentInfo selects the Employment Info tab and scrolls it into view; a later hash change selects Dates | | sample person, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues=/disabledFields= prefills and locks | the backend-format #/createChild=pet link on person 3 opens Add Pet with Person preset and disabled; Create closes the dialog, clears the hash, submits personId=3 and SQL shows the pet linked to person 3; /app/pet/create#/defaultValues={name, personId}/disabledFields={personId} prefills both, locks Person and saves them (SQL); /app/pet/create#defaultValues={speciesId} (no slash) prefills Species without locking | locked fields are disabled; unlocked preset fields stay editable | sample person -> pet (personId), petSpecies | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the form's current values | every search posts the form values as the values map of the v1 JSON body; create: choices narrow to the chosen Category and change when it changes; edit: the stored Item label shows, a new Category changes the choices; the chosen id persists (SQL) and the record view shows its label | Category empty: the unset variable makes no matches for a form, shown as No options found; an item outside the filter is not offered, even when searched by name; the backend refuses an out-of-filter id lookup and returns only the filtered choices | recPvPick itemId (recPvItem source filtered on categoryId = ${input.categoryId}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | developer page for a table keeps its metadata sections and shows the API Docs & Playground section; with no application APIs it shows "This table is not available in any APIs." and no API or Version selectors; OpenAPI download and new-tab buttons remain fully visible before interaction at desktop, phone and 320px widths in light/dark modes; pointer/touch downloads yield the real specification and keyboard navigation opens its URL; specification labels fit wider application font metrics at desktop and phone widths without content clipping | a viewer persona sees the same empty state and cannot fetch any table spec | recordLab has no application APIs; person is exposed through the owned Acceptance API | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script creation | versions list with CURRENT on the current revision and per-file code for the selected version; an older version offers Edit and Activate; save a new version with a commit message (SQL: new script_revision row, its file and script.current_script_revision_id; persists after reload); test run output and log lines equal the backend testScript response; a failing run shows the error the backend returns; logs list the script_log rows of the selected version with their lines; docs show the script type help text and sample code; create a script for an empty associated script field; local QQQ helper and keyword suggestions support keyboard and pointer/touch insertion, native undo/redo, both-theme accessibility, and SQL-verified revision persistence | a viewer cannot save or test a revision (no Edit or Test offered; the backend refuses storeScriptRevision and testScript); a viewer cannot create a script (no Create Script offered; the backend refuses the create route); a table without associated scripts shows none | scriptLab (RecordsFixtures: Alpha with script 101, revisions 101-102, logs 101-102; Beta without a script) on the scripts tables from WidgetsFixtures | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | e, c and n open edit, copy and create with the right values; a opens the audit history; d opens the delete confirmation, Escape keeps the row (SQL), confirming deletes it (SQL); ? lists Create a New Record, Edit/Copy/Delete/Audit the current Record with their keys | no shortcut fires while the audit or keyboard help dialog is open; keys typed in the Jump to input are text; viewer persona: n, e, c, d do nothing (a still works); the backend refuses insert and delete (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and the phone action sheet fits, scrolls and keeps focus | The title stays on one line and never overlaps the view toggle, Audit or record actions; neither the page nor its main region scrolls sideways; The back link, value links and header controls are touch-sized (44 x 44 px) under a coarse pointer; The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit | A read-only user gets no phone Actions trigger (asserted in REC-046) | recordLab 1 (Lab: Alpha); phone (412 px touch), tablet (810 px touch) and desktop viewports | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-sized, possible-value lists open on screen and the phone action bar stays reachable | Create Field Lab: no sideways scroll, every control at least 44 x 44 px by touch; on a phone Save stays on screen; boolean, date, time, text and rich text values persist; Edit Record Lab: the owner list opens inside the screen with touch-sized options; the picked owner persists | | fieldLab create (every field type), recordLab 1 edit (possible values, code editor, password reveal, file uploads) | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON record field | the region field starts from stored JSON and appears in the record edit form; changing the region persists the new JSON in inputValues and survives reload | clearing the required region blocks the write; the transient region field is absent from the table multipart payload | accWidgetHost 1 with accHostRecordVariables and hidden inputValues field | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and chosen IDs persist | v1 metadata exposes the inline enum and the edit form shows the held label; a label-prefix search filters options and the CHIP adornment colors and icons them; choosing High stores HIGH in SQL and shows High after reload | a substring that is not a prefix offers no options; the picker makes no possible-values request | recordLab priority field with an inline enum and CHIP adornment | pass (chromium) | [QRun-IO/qqq-frontend-next#721](https://github.com/QRun-IO/qqq-frontend-next/issues/721) |
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | v1 table and field metadata expose the Material supplemental adjuster settings; the table onLoad route supplies a note before render; the name onChange route changes a field label and clears its value; the field rule clears status | a backend lock message disables Save for a preset name | FormAdjusterFixtures.java adjusterLab table with a table onLoad adjuster, name onChange adjuster and clear-target field rule | pass (chromium) | [QRun-IO/qqq-frontend-next#720](https://github.com/QRun-IO/qqq-frontend-next/issues/720) |
| REC-064 | Record sections render qualified fields from readable exposed joins | record GET sends a selected LEFT join with qryItemJoinPerson; the joined person's first name appears in the Details section | unreadable join paths are removed from sections and GET descriptors by the same permission filter as query pages | QueryFixtures.java qryItem joins person and shows person.firstName in Details | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | Selecting South Store shows S-KIWI on the record view and edit/copy forms; Every South record GET includes tableVariant {type: qryStore, id: 2}; Switching to North Store shows N-APPLE for the same record ID with variant id 1 | The duplicate ID must not show North Store's N-APPLE value | QueryFixtures.java qryStock (same record ID in North and South variants) | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | Unrelated edit omits unchanged timestamp; Copy sends the original zoned instant and preserves source/copy SQL timestamps | Copy must not select the first occurrence of a repeated hour or truncate fractional seconds | fieldLab; browser America/New_York; repeated DST hour with subsecond precision | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
## Permissions, sessions, authentication and interaction quality
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend refuses | noPets sees Person but no Pet / Pet Note in navigation; person record neither shows nor requests the hidden pet association | direct link /app/pet shows unknown resource with no data request; API query/get of pet returns 403, table metadata 404 | tests/acceptance/fixture/AcceptanceSampleServer.java personas (noPets) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denial without loading data | admin lists the ledger rows; noPets sees the Security Ledger link | noPets list, record, create and edit links show the permission message and request no records; API query/get/insert/update return 403 and rows are unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityLedger, base name pet, noPets) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | viewer list has no Create button and the dashboard has no Create quick action | /app/person/create shows the permission message with no form; POST /data/person returns 403 and the person count is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | viewer record view has no Edit action | /app/person/1/edit shows the permission message without loading the record; PUT /data/person/1 returns 403 and the name is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothing | record view and bulk action bar have no Delete | /app/person.bulkDelete is unknown to the viewer; DELETE /data/person/1 and bulkDelete init return 403; the row remains | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | viewer record view has no Copy action | /app/person/1/copy shows the permission message; nothing is inserted | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium) | |
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, backend refuses | viewer has no Actions menu; noProcesses' Actions menu offers only the table's bulk actions (table rights) and no standalone process; no process links; noProcesses keeps table rights (Create offered) | /app/clonePeople and /app/greetInteractive are unknown and make no process request; legacy and v1 process init return 403; no person is cloned | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer, noProcesses) | pass (chromium) | |
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses with no side effect | admin runs Security Audit and one audit row is written; viewer sees the audit tile disabled and no dashboard quick action for it | viewer direct link shows the permission message and never calls init; process init returns 403 and the audit log stays empty | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityAudit process) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-009 | Widget restrictions: hidden widgets absent, disabled widgets show a permission state without loading data, backend refuses | admin sees both pet widgets with their content; noPets sees the open bulletin widget | noPets: hidden widget absent, disabled widget shows "You do not have permission to view this data." and no /widget request; GET /widget/<denied> returns 403 | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetWidget, securityPetDisabledWidget) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-010 | App restrictions: hidden apps absent from navigation and unknown by link; disabled apps keep their children gated | admin sees both pet apps | noPets: Pet Vault App absent and /app/securityPetApp unknown; Pet Disabled App children stay permission-gated | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetApp hidden, securityPetDisabledApp disabled) | pass (chromium) | [#651](https://github.com/QRun-IO/qqq/issues/651) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-011 | Record-level security (sharing demo): users see only their own or shared saved views/reports; direct links and writes to others are refused | alice lists and opens her own saved view | bob's list omits alice's view; bob's direct link shows not found; bob's GET/PUT/DELETE of alice's view are refused and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (alice, bob) | pass (chromium) | |
| SEC-012 | Record security lock on a data table: list filtered to the key, other users' records unreachable, forged owner refused | alice sees and edits only her notes (persisted) | bob's direct link to alice's note shows not found; bob's update/delete of alice's note and insert with alice as owner are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityNote locked by sample user) | pass (chromium) | |
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the mask, heavy fields not loaded in lists | REVEAL password can be revealed on request; editing another field keeps the stored password | hidden value absent from list, record, edit form and API responses; masked password shows only the mask; the list query does not carry the heavy BLOB | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityVault) | pass (chromium) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct links | admin can list and view the archive | no Create/Edit/Delete offered; create and edit links explain the table cannot be changed; backend insert/update/delete are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityArchive) | pass (chromium) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is written, reload reflects the new rights | after reload the viewer rights are shown | saving an open edit form after revocation shows a permission error and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (admin then viewer) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | lands on the requested page with rows; user menu shows Alice (sample) / sample:alice | | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#332](https://github.com/QRun-IO/qqq/issues/332) [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per-user client data is cleared | Sign in returns to the original page | after logout a protected page (full load) stays on login and reads no data; a different user signing in on the same browser never sees the previous user's cached pages or recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and mid-use) | expired on first load returns to the page; expired mid-use returns to the clicked record | no hang on "Setting up anonymous session" | tests/acceptance/fixture/AcceptanceSampleServer.java personas (expired) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | | login shows "Sign-in was denied", no navigation or data is rendered | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | in-app returnTo with query string is honoured | absolute, protocol-relative, backslash and javascript: targets land on /app; paths that normalize to another origin (/.//host, /app/..//host, /app/../\host) land on /app | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend token exchange, session, reload resume) | authorize request carries S256 challenge, state, client id, scopes and /token redirect; backend exchanges code + verifier with client secret; user_session row stored; provider identity is displayed; reload resumes without the provider | | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no session | | a state-matched access_denied shows the provider description once; a state mismatch shows a generic error and is rejected before any token exchange; a rejected code exchange shows the failure; no user_session row | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | user_session row deleted and provider session ended | protected page after logout returns to the provider login | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#674](https://github.com/QRun-IO/qqq/issues/674) |
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | returns to the same page after provider sign-in | revoked session gets 401, not stale data | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT validation via JWKS) | sign-in and data access through the Auth0 module; logout returns through the provider | provider denial is reported | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=AUTH_0 | pass (chromium) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-030 | AUTH_0 against a real Auth0 tenant | real tenant sign-in/logout | | Requires an Auth0 tenant QRun does not own in CI | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real Auth0 tenant; the AUTH_0 flow is covered against an owned Auth0-compatible provider by SEC-029/SEC-031; real-service testing is follow-up work.) | |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in again | deep link loads data as Anonymous | after logout the page stays signed out until Sign in | SecurityAcceptanceServer -Dqqq.security.auth=FULLY_ANONYMOUS | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, never silently treated as another type | | login shows "Unsupported authentication type: ACCEPTANCE_UNKNOWN" and no data or session is requested | SecurityAcceptanceServer -Dqqq.security.auth=UNSUPPORTED (v1 authentication metadata names type ACCEPTANCE_UNKNOWN; TABLE_BASED is supported since QRun-IO/qqq#700, see SEC-034..SEC-037) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session cannot be replayed | logout response expires sessionUUID and sessionId | a logged-out Auth0 sessionUUID replayed against the API gets 401 | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#674](https://github.com/QRun-IO/qqq/issues/674) [#675](https://github.com/QRun-IO/qqq/issues/675) |
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSession with Authorization: Basic creates a stored session, the session user is shown and a reload resumes it | login form shows the logo and app name before any session exists and reads no data; credentials travel once as Basic credentials (password with colons), never in the body or browser storage; lands on the requested page with rows; user menu shows Tess Table (table-based) / tess.table; one session row for the user, matching the sessionUUID cookie; reload keeps it | without the session cookie the backend refuses a read (401) | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, and the form recovers | correct credentials after a refusal sign in to the requested page | empty fields are named and nothing is sent; wrong password and unknown user both show "Incorrect username or password."; no session row is stored and no data is read; the password field is cleared | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-036 | TABLE_BASED session expiry: a session idle past the inactivity timeout gets a 401, the user signs in again and returns to the record they opened | expired mid-use returns to the clicked record after signing in again | the expired session is refused by the backend ("Session is expired.") | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) + /acceptance/expire-table-sessions | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-037 | TABLE_BASED logout: POST /qqq/v1/logout deletes the stored session and clears both cookies, protected pages stay on the form, and the next user signs in as themselves | a different user signs in on the same browser and is shown as themselves | the ended session cannot be replayed with either cookie (401); after logout a protected page (full load) stays on the form and reads no data | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
| SEC-038 | Dashboard documents carry a strict Content-Security-Policy (script-src self plus the hash of each inline script, object-src none, base-uri/form-action self, frame-ancestors none), X-Frame-Options DENY, Referrer-Policy and Permissions-Policy | login, token, app, list, record, edit and 404 documents send the policy and headers; the script-src hashes equal the hashes of the inline scripts in each served document; pages hydrate and navigate under the policy with no violation | another web site framing the dashboard gets no content; an injected inline script and inline event handler do not run and are reported as violations | tests/acceptance/fixture/AcceptanceSampleServer.java (javalin mode); with QQQ_ACCEPTANCE_MODE=standalone the container image server (standalone/qqq-server.mjs) | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
| SEC-039 | The policy allows the configured identity provider origin (OAUTH2 discovery, AUTH_0 browser token exchange) and no other connection target | OAUTH2: connect-src is self plus the provider origin; sign-in completes with no violation; AUTH_0: connect-src is self plus the Auth0 domain; the browser token exchange completes with no violation | a fetch to any other origin is refused and reported (connect-src) | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2\|AUTH_0 | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) |
| SEC-040 | Application override hook and metadata-derived origins: the application adds its own origins (QApplicationJavalinServer.withNextDashboardSecurityHeadersCustomizer); QuickSight and custom component bundle origins come from widget metadata | the QuickSight embed, block image and audio from the loopback service and the custom component bundle load with no violation; the policy lists exactly the configured origins | a frame from an origin the application did not configure is refused and reported (frame-src) | tests/acceptance/fixture/WidgetsFixtures.java loopback service | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
| SEC-041 | The login page explains known sign-in error codes and never displays text taken from the link | direct links with access_denied and other known codes show their safe message; other well-formed codes show the code; Try again signs in through the provider | free text in ?error= is replaced by a generic message | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-042 | Rendered HTML (field values, widgets, process steps, help, banners) cannot add forms, form controls, style sheets or page-covering positioning | formatting in a stored HTML value is kept; the stored value is unchanged in the database | a stored form with a password input, a style element and a position:fixed overlay render as plain content | tests/acceptance/fixture/RecordsFixtures.java fieldLab.htmlValue | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-043 | Per-user browser data (recently viewed records, stored identity) is bound to the signed-in identity | the same user keeps their recently viewed records across reloads | a different user signing in on the same browser without a logout does not see the previous user's recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas and sample users | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arrived over HTTPS | manageSession and API responses set the cookies with Path=/ and SameSite=Lax; the browser stores them as Lax | over plain HTTP the cookies are not Secure (sign-in keeps working); behind X-Forwarded-Proto https they are | tests/acceptance/fixture/AcceptanceSampleServer.java | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a session from the cookie never hands its token to script | after sign-in (MOCK, TABLE_BASED, OAUTH2) the browser stores both cookies HttpOnly, naming the current session; a reload resumes the session through POST /qqq/v1/manageSession with an empty body; the response carries the session values and no uuid | document.cookie never shows sessionUUID or sessionId; an in-page POST /qqq/v1/manageSession {} (what injected script could send) answers without the session token | tests/acceptance/fixture/AcceptanceSampleServer.java (MOCK); SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-expired or revoked session sends the user to sign in again, and a new password or OAuth2 sign-in points both cookies at the new session | TABLE_BASED: a first visit shows the form without a session request; an idle-expired session is refused, the form returns without a resume attempt, and signing in again returns to the record; OAUTH2: a session revoked on the server sends the user through the provider and back, with new HttpOnly cookies; logout: the server deletes the session and expires both cookies; a protected page stays signed out without a session request | a logout that never reached the server leaves the earlier user's cookies; the next user's sign-in replaces both, and a reload keeps the new user | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | edit saved by keyboard persists (SQL) | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-002 | Keyboard: run a process through its steps without a pointer | greetInteractive completes and shows results | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | keyboard delete confirmation deletes the row (SQL) | Escape cancels without deleting; focus returns to Delete | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) [#683](https://github.com/QRun-IO/qqq/issues/683) [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | command menu navigates to a table | Escape closes and returns focus | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | no serious or critical violations on each page | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce their error | labels match metadata labels | submitting empty required fields sets aria-invalid and a described error | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | | missing required value is named and focused; backend rejection is shown verbatim; SQL unchanged | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal scrolling | drawer navigation to a list, open and edit a record on a phone viewport | no horizontal page overflow | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | busy indicator while the real request is held, then rows | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | retry succeeds | the failure is explained, not a blank page | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | exactly one row inserted (SQL) | second click while pending is ignored | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | record edited through the API shows the new value on revisit | record deleted through the API shows not found | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#683](https://github.com/QRun-IO/qqq/issues/683) |
| INT-013 | Empty state: a query with no results explains it and offers the create action | no-match filter shows the empty message and Create | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | Try again after recovery signs in | network failure shows "Sign-in failed" | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bug opens the recorder; secure values blurred; captures sanitized | extension absent or capability disabled: no Jam chunk, meta or request; enabled: jam:team meta, one initialize with sanitizers, jam.metadata; Report a bug: POST report then Recorder.open with jam-title and jam-reference; Recording Link jam-* parameters survive the OIDC sign-in; QA mode preference refetches the capability | no Recording Link configured: toast, no recorder; Authorization/Cookie headers, secure body fields and JWTs redacted | qbit-qa-capture (not yet released); Vitest: src/lib/qa-capture/*.test.ts, src/components/qa-capture/QaCapture.test.tsx | excluded (Pending: not yet approved by the owner. No real-backend fixture until qbit-qa-capture is released; the row is covered by Vitest until then (QA capture PR, 2026-10-15).) | |
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | WARNING alert renders strong HTML and each bullet as sanitized HTML | hideWidget and empty alerts render nothing (WID-063) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlert | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-002 | barChart widget: one bar per label in backend colors | bars carry per-point backgroundColors; accessible data table has exact values | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-003 | chart output from an untyped widget renders as a bar chart | metadata type absent, payload type chart, values rendered | zero-value month has no drawn bar | sample greetingsApp/PersonsByCreateDateBarChart | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-004 | divider widget renders a rule without card chrome | hr rendered, no label or card | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accDivider | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | display value wins, zero shown, icon color, indent | empty field list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accFieldValueList | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | sublabel and sanitized footer HTML shown | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accGeneric | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | negative bar extends left of the zero line; height from payload | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accHorizontalBarChart | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-008 | html widget (no-code) renders sanitized user HTML | velocity output with i/b/u and centered wrapper | empty html renders an empty body (WID-052) | sample SampleWidgetsDashboard/SampleHTMLWidget | pass (chromium) | |
| WID-009 | lineChart widget: line with a point per label and description | points carry exact values | | sample SampleWidgetsDashboard/SampleLineChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-010 | smallLineChart widget: compact line with title and HTML description | title, strong description text, 5 points | | sample SampleWidgetsDashboard/SampleSmallLineChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-011 | location widget: image, title, description, address and footer | owned image loads from the owned service | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accLocation | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | every group and value matches the payload | empty group list shows empty state (WID-052) | sample SampleWidgetsDashboard/SampleMultiStatisticsWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-013 | multiTable widget renders each table with its label and rows | two labelled tables with their rows | empty table list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accMultiTable | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-014 | pieChart widget: colored slices, legend values and subheader | slice fills, legend label: value, main number, up/good change | | sample SampleWidgetsDashboard/SamplePieChartWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | iframe src is the owned embed URL and its content renders | metadata never exposes the secret key | tests/acceptance/fixture/WidgetsFixtures.java repointSampleQuickSight (greetingsApp/QuickSightChartRenderer against the owned loopback endpoint) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-016 | statistics widget: count, context, colored percentage change and links | decrease with increaseIsGood is bad; increase with increaseIsGood=false is bad; count links to countURL | | sample SampleStatisticsWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStatisticsGood | pass (chromium) | |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | bars of one label share x; dataset colors | | sample SampleStackedBarChartWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStackedBars | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | states and current link | empty steps show empty state (WID-052) | sample SampleWidgetsDashboard/SampleStepperWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | rows and sticky last row match payload | no rows shows noRowsFoundHTML or default (WID-052) | sample SampleWidgetsDashboard/SampleTableWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-020 | usaMap widget plots each marker with name and coordinates | 3 markers placed west to east by longitude; marker list | empty marker list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accUsaMap | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-021 | process widget runs the sample interactive process inline | form fields render, submit, result greeting shows | viewer persona: process denied contained (WID-061) | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess (greetInteractive) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-022 | parentWidget renders its children in a 12-column grid | children render with their own data | tabs (WID-055), denied/missing children (WID-056) | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentGrid | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | every block value, direction, link and tooltip | no blocks renders an empty composite (WID-052) | sample SampleWidgetsDashboard/SampleBigNumberBlocksWidget | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-024 | childRecordList widget without association binding lists joined child records | rows link to child records, Showing 2 of 3, View All filter link | host with no children shows empty state | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-025 | customComponent widget loads its bundle and renders the named component | component receives widget metadata and data | missing bundle shows contained error (WID-062) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponent (owned bundle) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | expression, backend description and time zone | record without expression shows No schedule set | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-027 | dynamicForm widget shows labeled values from the payload | labels and values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostDynamicForm | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-028 | dataBagViewer widget lists versions and shows the selected version contents | newest version current and shown; selecting older version shows its JSON | bag without versions shows empty message | tests/acceptance/fixture/WidgetsFixtures.java dataBag/1 section accDataBagViewer (owned dataBag tables) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-029 | pivotTableSetup widget shows, edits and clears the saved report's pivot definition | rows/values labels from the source table; modal OK updates only the host draft; host Save persists rows, columns and aggregate values with SQL readback and reopen; keyboard row reorder and value move preserve saved configuration; turning the pivot off clears the stored definition on host Save | report without pivot shows message; missing row fields and aggregate functions keep the editor open; Cancel discards added fields without changing the database; used group/value fields are excluded from choices; changing a numeric value to text clears an incompatible aggregate | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned pivot saved report | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-030 | filterAndColumnsSetup widget shows the saved report's filter, sort and columns | criteria with field labels and operator phrases, columns by label | report without filter shows No filters | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned filtered saved report | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-070 | filterAndColumnsSetup edits a saved report's filter, sort, columns and variable values through the host form | Edit Filters and Columns opens the report setup dialog; OK writes queryFilterJson and columnsJson to the host form, and Save persists a changed sort to the backend; assigning a filter variable persists FilterVariableExpression in the backend JSON and shows ${VARIABLE} in the editor; import a saved view, reset changes to that view or reset to a new default draft without updating the saved view | Cancel discards the draft; OK without host Save leaves the backend row unchanged; preview and Open in new window block a query while a filter variable lacks a value; resetting and cancelling leave both the report and all saved views unchanged | prime-sharing savedReport 1 (Pet Species Report, no pivot table); saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-071 | a WIDGET-adorned process form field renders filter setup and writes its own value | the widget request names the adorned field, the editor updates its filter, and the next process screen receives the JSON | other widget output fields are not written to the adorned field's host form | ProcessesFixtures.java prcFieldWidget with a queryFilterJson WIDGET adornment | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-072 | API-versioned report setup loads table metadata and previews through the application API | the report editor loads person metadata and its preview rows and count through the real API-aware v1 routes | an unsupported API version returns 404 | ApiVersionFixtures.java acceptanceApi 2026.Q3; savedReport 102; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-073 | report setup editor uses the full server-backed grid for paging, sorting and column sizing | grid preview hides selection, changing page size changes the real backend query, header sorting changes the real backend query, and keyboard column resize persists in saved columns JSON; create a report from a query, switch to Audit, save and reopen, verify stored columns and download the CSV; filtered column statistics totals and exported value counts match SQL; closing statistics and cancelling the editor leave saved data unchanged; a table without COUNT still shows real preview rows and page copy matches SQL; variant-backed report previews choose, switch and remember the backend variant for query/count, copy and statistics on ordinary and application-API routes; complete nested criteria mark preview column headers; opening the indicator edits the existing condition and real preview rows match SQL; configured omitted joins disappear from new preview column/filter choices while saved joined values, editable criteria and column positions survive; changed results and reload match SQL; editor Cancel, Basic/Advanced, OK, host Save and reopen preserve joined criteria, sorts, order and width; hidePreview hides only the read-only summary; the editor retains real query rows, sorting and Refresh; Cancel, OK, host Save and reopen preserve the intended sort with the flag enabled and disabled; column menus fit 320px and 720px windows; keyboard End/Escape restores focus and pointer Copy/full-query and sorting remain reachable | selection pseudo-column and incompatible previous-table fields are excluded from saved reports; full-column copy refuses results over the configured limit; an empty preview offers no column copy action; cancellation preserves the saved report; no query before selecting a required variant; an old variant copy response cannot overwrite a later copy; variant changes do not persist report edits; incomplete criteria do not mark columns; opening an indicator adds no condition; read-only preview edits and editor Cancel leave stored report JSON unchanged | savedReport 1 targeting petSpecies without a pivot; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
| WID-031 | rowBuilder widget without association binding shows its rows read-only | column labels from widget fields, row values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostRows | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | current revision marked and its file contents shown; selecting older revision | | tests/acceptance/fixture/WidgetsFixtures.java script/1 (owned memory script with 2 revisions) | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | real embed URL | | requires AWS credentials and a QuickSight dashboard (external service) | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real AWS QuickSight dashboard; the widget and renderer are covered against an owned loopback provider by WID-015; real-service testing is follow-up work.) | |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | order matches metadata; 12 spans the grid; 4 is a third | | sample SampleWidgetsDashboard | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | plain widget has no card border; footers render | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accPlain, widgetGallery/accGeneric | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | icon names, colors and placement | | sample SampleWidgetsDashboard | pass (chromium) | [#538](https://github.com/QRun-IO/qqq/issues/538) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | hover and focus show, Escape hides | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-044 | widget help content (HTML) on the label | help icon shows sanitized HTML help | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accHelp | pass (chromium) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-045 | reload button re-renders the widget from the backend | render count increments per reload | no reload button without showReloadButton | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accReload | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-046 | export button downloads the widget's csvData as CSV | file bytes match Material quoting rules | no csvData: no-data message and no download | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accExport, accExportEmpty, accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-047 | PVS dropdown from the widget payload: selection is sent as a parameter and re-renders | options from payload, choice reaches renderer, clearing removes it | non-stored selection is not persisted | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accDropdownHtml | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-048 | DATE_PICKER dropdown sends the chosen date | date reaches the child renderer | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | message then children render with selections | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | localStorage qqq.widgets.dropdownData.<widget>.<param>; reload keeps selections | removed option in storage is not selected | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | error message, retry re-requests, neighbor content | no error toast, no console error | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accError + accHealthy | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-052 | empty payloads show descriptive empty states for every display type | table, multiStatistics, charts, stepper, statistics 0, composite, multiTable, field values, map, html | | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accEmpty* | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-053 | malformed payloads are contained to their own widget | chart, table, composite, stepper, multiStatistics show a format notice | no crash, no console error, neighbors render | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accMalformed* | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | admin sees it | noPets: no card, no request, GET /widget 403, renderer not called | tests/acceptance/fixture/WidgetsFixtures.java widgetPermissions/accDenied (pet.accDenied.hasAccess) with the noPets persona | pass (chromium) | |
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | tab click, arrow keys, stored tab restored after reload | | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentTabs | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-056 | parent widget skips denied and unknown children without requests | allowed children render | noPets: denied child absent and not requested; unknown child skipped | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentDenied, accParentMissing | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-057 | composite renders every block type with its values and styles | TEXT, BIG_NUMBER, UP_OR_DOWN_NUMBER, NUMBER_ICON_BADGE, ICON, TABLE_SUB_ROW_DETAIL_ROW, PROGRESS_BAR, DIVIDER, IMAGE, AUDIO, INPUT_FIELD, BUTTON; Unmapped legacy icon names render local glyphs in light/dark desktop/mobile modes; metadata navigation and main tiles preserve color, sizing, existing Lucide mappings and same-origin font delivery | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks and accLegacyIcons | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-058 | composite layouts and slot links/tooltips | FLEX_COLUMN, FLEX_ROW, BADGES_WRAPPER, TABLE_SUB_ROW_DETAILS, FLEX_ROW_SPACE_BETWEEN, FLEX_ROW_CENTER, FLEX_ROW_WRAPPED; link to /app/person with tooltip | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-059 | unsupported block type shows a contained warning | Unsupported block type: OWNED_UNKNOWN; neighbor healthy | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocksUnknown | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-060 | record-view section widgets receive the record context (id, tableName) | record 1 and 2 show their own values | renderer enforces the user's table read | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost sections accHostHtml, accHostFieldValues | pass (chromium) | |
| WID-061 | process widget for a persona without process permission is contained | error shown in the widget; neighbor healthy | process metadata 403, no process run | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess with the noProcesses persona | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-062 | custom component whose bundle cannot load shows a contained error | Error loading MissingOwnedComponent | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponentMissing | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-063 | alerts with hideWidget or without HTML render nothing | no card rendered | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlertHidden, widgetStates/accEmptyAlert | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| RPT-001 | TABLE report runs through its process and downloads CSV matching the database | CSV header labels and every person row from SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-002 | TABLE report downloads XLSX with the same rows | sheet rows match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-003 | TABLE report downloads JSON with the same rows | JSON objects match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-004 | SUMMARY report groups rows with counts and a totals row | pets per owner match SQL group by; Totals row | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetSummaryReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-005 | PIVOT report produces a native pivot table in XLSX | xlsx contains the pivot table and cache over the rows sheet | CSV omits the pivot view (no native pivots) | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetPivotReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#484](https://github.com/QRun-IO/qqq/issues/484) |
| RPT-006 | report input fields: required validation and the value filters the rows | minimumId filters rows >= value; Standard report inputs retain compact widths and an inline Generate Report action without a process header | empty required input blocks submit | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonInputReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-007 | report permissions: restricted report denied in UI and by the server | admin can run it | noPets: no-permission message, legacy route 403 | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accRestrictedReport with the noPets persona | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-008 | reports appear in app navigation and open their run page | sidebar REPORT nodes navigate to the report page | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports app | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#651](https://github.com/QRun-IO/qqq/issues/651) |
| RPT-009 | saved report view shows its filter/columns and pivot setup | filter criteria, columns, pivot rows/values labels | | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned saved reports | pass (chromium) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| RPT-010 | saved report renders through renderSavedReport and downloads the saved columns and rows | format chosen, CSV contains the saved columns and every species | | prime-sharing savedReport 1 (Pet Species Report) | pass (chromium) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#661](https://github.com/QRun-IO/qqq/issues/661) |
| RPT-011 | streaming report route for reports without a process | CSV streamed from /reports/{name}?format=csv matches SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accStreamedReport | pass (chromium) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-012 | scheduled report: create a schedule for a saved report and view its cron schedule | schedule persisted with cron description; record view shows the schedule widget; Add new on the saved report opens the Add Scheduled Report dialog over it with Saved Report preset and locked (Material #/createChild=); Create submits savedReportId=1, closes the dialog and reloads the child list | invalid cron expression rejected with message | scheduledReport (memory) for savedReport 1 | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) [#714](https://github.com/QRun-IO/qqq/issues/714) |
| RPT-013 | sharing: the owner shares a saved report with a user (Read-Only) | dialog lists Bob; shared_saved_report row READ_ONLY | | prime-sharing savedReport 1 owned by alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#444](https://github.com/QRun-IO/qqq/issues/444) |
| RPT-014 | sharing: change a share's scope to Read and Edit | row scope READ_WRITE | | prime-sharing savedReport 1 | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-015 | sharing: remove a share | row deleted, list empty | | prime-sharing savedReport 1 | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-016 | sharing record security: another user cannot see an unshared report until it is shared | bob sees it after sharing (UI list + API) | before sharing: not in bob's list, GET 404 | prime-sharing savedReport 1 (alice), user bob | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-017 | sharing: only the owner may share | Share disabled with reason for the non-owner | server rejects a non-owner share; no row | tests/acceptance/fixture/WidgetsFixtures.java savedReport owned by casey shared with alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#371](https://github.com/QRun-IO/qqq/issues/371) |
| RPT-018 | sharing a saved view through the share dialog | shared_saved_view row for Casey | | prime-sharing savedView 1 owned by alice | pass (chromium) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| WID-064 | cronUI schedule editor on record forms: Basic days/hours/minutes, Advanced expression, live description, validation, time zone | Basic mode builds weekdays, hours and minutes; the live description equals the cronDescription QQQ stores; Basic mode builds selected dates, Clear resets the schedule, and the Advanced hint names the part under the keyboard caret; Advanced mode edits the stored expression; the live description equals the backend widget description; expression and time zone persist (SQL); an expression Basic mode cannot show opens Advanced with Basic disabled and the reason | an out-of-range expression shows the error live and marks the input invalid; saving without a schedule shows the required error in the editor, focuses it, and saves nothing | scheduledReport (memory) for savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron with a time-zone enum possible-value source | pass (chromium) | [#702](https://github.com/QRun-IO/qqq/issues/702) |
| WID-065 | record view mounts only the active layout, so each section widget mounts and loads once | desktop tabs: every section widget is in the DOM once and requested once; no accordion is mounted; phone accordion: only open sections mount; each widget is requested once, on opening | the desktop tab layout is not mounted at phone width | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost | pass (chromium) | [#706](https://github.com/QRun-IO/qqq/issues/706) |
| WID-066 | Widget metadata and widget data come from the v1 API | v1 /metaData lists permitted widgets with hasPermission and the reports map; the dashboard renders its widgets from POST /qqq/v1/widget/{name} and makes no legacy metadata or widget request | a denied widget is absent from v1 metadata, never requested, and refused (403) by POST /qqq/v1/widget without rendering | widgetPermissions (accHealthy, accDenied), accPersonReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-067 | a widget whose payload is a single leaf block (Material block widget) renders that block | The backend payload is the TEXT block itself (blockTypeName TEXT, no blocks list); The widget shows the block's text as a TEXT block | No empty COMPOSITE container is rendered in its place | WidgetsFixtures.java accLeafBlock (COMPOSITE widget whose renderer returns a TextBlockData) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| WID-068 | table widget Material extras: typed cells (numbers with separators, html, htmlAndTooltip, image, composite), hidden helper columns, fr widths, sub-rows, sticky header, column-header help, paging (10 by default, numbered pages, jump-to-page, entries-per-page), export built from columns and rows, and a multi-table widget with an export button and footer per table | Default cells show numbers with thousands separators; html cells render sanitized markup; an htmlAndTooltip cell shows the row's tooltip HTML on hover, focus or tap; an image cell shows the row's image, label and total; a composite cell renders its blocks; Columns of type hidden (tooltip, image helpers) are not shown; fr widths share the width left by fixed widths (2fr is twice 1fr; a 180px column keeps 180px); A row with sub-rows expands and collapses them (nested rows are shaded; a sub-row expands its own sub-rows); The columnHeader=name help slot shows its HTML help on the Name header; Without rowsPerPage 10 of 40 rows show, with numbered page buttons; the entries-per-page select (5-25) changes the page size; more than six pages become a jump-to-page input; the header stays at the top while the fixed-height body scrolls; Export without csvData downloads the Material CSV of every column (hidden ones too), quoted, with link text and without icon glyph text; Each table of a multi-table widget has its own label, export button (csvData or columns and rows) and footer; the widget itself has no export button | Icon glyph text (open_in_new) never reaches the CSV; Collapsed sub-rows are not rendered; A sub-table's footer is not shown under another sub-table | WidgetTableChartFixtures.java widgetTableCharts/accTableCells, accTablePaging, accMultiTableExtras | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-069 | chart widget Material extras: theme color names, legend toggles, single-series legends (horizontal bars, line badges), full pie with percent tooltip, stacked backgroundColor, stacked tooltip and right integer axis, rotating category labels, small line y ticks and grid, bar chart As of line | Pie slices named info and success fill with the Material theme colors; the pie is full (no inner radius); Hovering a slice shows its label, value and percent of the total; Clicking a legend entry hides that slice or series and clicking again shows it; Stacked datasets fill from their singular backgroundColor (a theme name or a CSS color); the tooltip names only the hovered dataset; the y axis sits on the right with whole-number ticks; long category labels turn; A single-series horizontal bar chart has a legend; a line chart names its series in badges above the chart; A small line chart shows y-axis values and a horizontal grid; A barChart widget ends with an As of line naming today's date | A hidden series draws no bars or points; No y-axis tick of the stacked chart is fractional | WidgetTableChartFixtures.java widgetTableCharts/accPieNamed, accStackedExtras, accLineBadges, accHorizontalLegend, accSmallLineTicks, accBarAsOf | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| RPT-019 | render report input step shows only its fields; a report without variables adds no no-fields message | Report Format, Email To and Email Subject are shown; the values widget renders nothing | no "No fields" text on the input step | savedReport 1 (Pet Species Report, no variables) | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
| RPT-020 | Report files download from the v1 download and report routes | a process report links its file at /qqq/v1/download/... and the file has every row; a report without a process streams from /qqq/v1/reports/{name}?format=csv with every row | an unregistered server file or storage reference is refused (403); a restricted report is 403, an unknown one 404, a missing format 400 | accPersonReport, accStreamedReport, accRestrictedReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus; Native standalone TIME controls increment by one second and retain second-level values. | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | UTC timestamp displays the correct local date, time and seconds | Do not slice UTC digits into a local-time field | accTypedInputs in America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
## Contextual help, help search and authoring
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| HLP-001 | App and widget help | summary tooltip; keyboard panel; sanitized long markdown; focus restoration; light and dark; desktop and mobile | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-002 | Table, field and section help | view screen; edit screen; create screen; role selection | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-003 | Query columns and table action help | column summary; query table header; action is not invoked by help | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-004 | Process, step and record action help | process header; step header; record menu keyboard control | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-005 | Help search | static Search help command; debounced help query; result summary and panel; Go to link | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
| HLP-006 | Help author affordance | key and specific role URL presets; standard create form; read-only users cannot author | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
The help rows above have not been executed locally; historical results above predate this change.
## Release notes UI API extension
`release-notes` 1.0.0 (qbit-release-notes 0.1.0-RC.1+) provides What's new at
`/app/whats-new`: category groups, new badges, append paging, safe links/screenshots,
release deep links, seen tracking and the user-menu unread count. Extension discovery
hides the menu and prevents requests on unsupported servers. Covered by the release-notes
client/page and Sidebar Vitest suites; real-backend acceptance remains pending.
+109
View File
@@ -0,0 +1,109 @@
<!--
Draft body of the GitHub release cli-v1.0.0-rc.1. This comment does not render on GitHub.
The rc.1 sequence (James; details and fallbacks in docs/release-runbook.md):
# 0. feat/m5-cli-release merged to main through a pull request; the orb pipeline on main is green
git checkout main && git pull --ff-only
# 1. every check and the seven binaries with the UI (prints the commands below with these versions)
node scripts/release.mjs
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077
# 2. optional while the certificates are not set up: both scripts do nothing without their env
scripts/sign-macos.sh && scripts/sign-windows.sh # each recomputes dist/SHA256SUMS
# 3. tags: v1.8.0-rc.2 makes the orb publish the npm packages (all four as dist-tag rc,
# because they are prereleases); cli-v1.0.0-rc.1 names the GitHub release
git tag -s v1.8.0-rc.2 -m "UI API client 1.8.0-rc.2, qqq 1.0.0-rc.1"
git tag -s cli-v1.0.0-rc.1 -m "qqq 1.0.0-rc.1"
git push origin v1.8.0-rc.2 cli-v1.0.0-rc.1
# watch the publish_release workflow: @qrun-io/qqq-ui-api-client 1.8.0-rc.2, qqq-core 1.0.0-rc.1,
# qqq-ui-api-conformance 1.6.0-rc.2, qqq-cli 1.0.0-rc.1 on GitHub Packages. Only if the orb release
# with PR #165 is not out yet, core is missing; then:
npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag rc
# 4. the GitHub release, a prerelease, with the binaries and their checksums
gh release create cli-v1.0.0-rc.1 --repo QRun-IO/qqq-cli --prerelease --title "qqq 1.0.0-rc.1" \
--notes-file docs/release-notes/1.0.0-rc.1.md dist/qqq-* dist/SHA256SUMS
# 5. Homebrew and Scoop from the uploaded checksums (once the tap and bucket exist)
gh release download cli-v1.0.0-rc.1 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-rc1
node scripts/update-packaging.mjs /tmp/qqq-rc1/SHA256SUMS
# copy docs/packaging/homebrew-tap/Formula/qqq.rb and docs/packaging/qqq.json, a PR on each
# 6. smoke what users get
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0-rc.1
scripts/verify-binary.sh ~/.local/bin/qqq --server http://localhost:8077
-->
# qqq 1.0.0-rc.1
The first release candidate of **qqq 1.0**: the command-line client and the full-screen terminal UI for
[QQQ](https://github.com/QRun-IO/qqq) applications, in **one binary per platform**. Everything comes from
the server's metadata through the QQQ UI API, so the same binary works with any QQQ application.
Works with servers on UI API 1.6.0 up to 2.0 (QQQ 4.1.0-RC.15 or later). `qqq --version` shows the range.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0-rc.1
```
Or download the binary for your platform below and check it against `SHA256SUMS`:
| Platform | File |
|---|---|
| macOS, Apple silicon | `qqq-darwin-arm64` |
| macOS, Intel | `qqq-darwin-x64` |
| Linux x64 / arm64 (glibc) | `qqq-linux-x64`, `qqq-linux-arm64` |
| Linux x64 / arm64 (musl, Alpine: `apk add libstdc++ libgcc`) | `qqq-linux-x64-musl`, `qqq-linux-arm64-musl` |
| Windows x64 | `qqq-windows-x64.exe` |
The npm package (`npm install -g @qrun-io/qqq-cli@rc` from GitHub Packages, Node 20+) has the CLI
without the terminal UI.
**macOS:** these binaries are not notarized yet. A binary downloaded with a browser is stopped by
Gatekeeper; clear the flag once with `xattr -d com.apple.quarantine ./qqq-darwin-arm64`. The installer
above does not set it. **Windows:** the `.exe` is not signed yet, so SmartScreen may warn on first run.
## Get started
```sh
qqq login --server https://your-qqq-server # device flow, browser (PKCE), Auth0, TABLE_BASED or a session
qqq home # what you can open and do
qqq table query person -w "lastName STARTS_WITH S" -o -id -l 5
qqq process run greetInteractive -r 1,2 -v greetingPrefix=Hi
qqq ui # the terminal UI on the same profile
qqq doctor # connection, sign-in, terminal, and the build for support
```
## What is in it
**The terminal UI (`qqq ui`)**: a classic full-screen UI with a function-key bar, Home, the F9 command
center and F2 go-to, a server-paged grid (sort, filter builder or grammar, quick search, statistics, saved
views, variants, export), records with forms, contingent pickers, uploads and downloads, audits, tags and
sharing, the process wizard, the bulk-load wizard, reports and saved-report editors, dashboards and
widgets that act, developer mode, F1 help from the application, every sign-in type, themes, and a terminal
check that finds tmux key and colour problems and offers fixes.
**The CLI (`qqq <noun> <verb>`)**: tables (query, count, stats, get, create, copy, update, delete, export,
children, downloads and uploads, variants, columns), bulk insert, edit and delete from CSV or Excel,
processes with every step screen, async jobs and cancel, reports and saved reports (pivots, schedules),
saved views, sharing, audits, widgets and dashboards as text, home and find, preferences, help, every
extension the server installs (`qqq esb`, `qqq collab`, ...), developer mode, and `qqq api` for any of
the 202 UI API operations. JSON, table, text, CSV and YAML output with JMESPath `--query`; shell
completion for bash, zsh, fish and PowerShell; sign-ins in the OS keyring; profiles like `aws --profile`.
**Parity**: every row of the web UI's acceptance matrix that applies to a terminal passes in both the CLI
and the UI, or is blocked on a filed server issue.
## Known limitations
- Text filters ask the server for case-insensitive matching, but servers do not read that option over the
UI API yet (qqq#1358), so matching stays case-sensitive until they do.
- Jobs need a server with durable jobs. Auth0 sign-in has not been tried against a real tenant.
- The Windows and Intel macOS binaries are cross-compiled and have not been run before this candidate.
Full list of changes: [CHANGELOG.md](https://github.com/QRun-IO/qqq-cli/blob/main/CHANGELOG.md).
+179
View File
@@ -0,0 +1,179 @@
<!--
Body of the GitHub release cli-v1.0.0. This comment does not render on GitHub.
The 1.0.0 sequence (James; details in docs/release-runbook.md):
# 0. release/1.0.0 fast-forwarded onto main; the orb (qqq-orb 0.8.33) pipeline on main is green, platform
# lanes included (.qqq-ci.yml `platforms:` turns on the macOS arm64 and Windows x64 lanes, which a
# release tag requires before publishing)
git checkout main && git pull --ff-only && git merge --ff-only release/1.0.0 && git push origin main
# 1. every check, the readiness gate (READY for a final) and the seven binaries with the UI
node scripts/release.mjs
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077
# 2. sign when the certificates are there (each recomputes dist/SHA256SUMS)
scripts/sign-macos.sh && scripts/sign-windows.sh
# 3. tags: v1.8.0 makes the orb publish the four npm packages, all as dist-tag latest because all four
# are final (qqq-orb PR #165); cli-v1.0.0 names the GitHub release
git tag -s v1.8.0 -m "UI API client 1.8.0, qqq 1.0.0"
git tag -s cli-v1.0.0 -m "qqq 1.0.0"
git push origin v1.8.0 cli-v1.0.0
# watch publish_release: @qrun-io/qqq-ui-api-client 1.8.0, qqq-core 1.0.0, qqq-ui-api-conformance 1.6.0,
# qqq-cli 1.0.0 on GitHub Packages, dist-tag latest (orb 0.8.33 publishes qqq-core and sets the tags)
# 4. the GitHub release, marked latest, with the binaries and their checksums
gh release create cli-v1.0.0 --repo QRun-IO/qqq-cli --latest --title "qqq 1.0.0" \
--notes-file docs/release-notes/1.0.0.md dist/qqq-* dist/SHA256SUMS
# 5. Homebrew and Scoop: QRun-IO/homebrew-tap and QRun-IO/scoop-bucket exist; they need the checksums
# commit after the GitHub release (use the uploaded sums, never a local rebuild)
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
# commit docs/packaging/homebrew-tap/Formula/qqq.rb to the tap (Formula/qqq.rb) and
# docs/packaging/qqq.json to the bucket (bucket/qqq.json); commit the same files here too
# 6. smoke what users get
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
scripts/verify-binary.sh ~/.local/bin/qqq --server http://localhost:8077
npm install -g @qrun-io/qqq-cli && scripts/verify-binary.sh qqq --cli-only
-->
# qqq 1.0.0
**qqq** is the command-line client and the full-screen terminal UI for
[QQQ](https://github.com/QRun-IO/qqq) applications, in **one binary per platform**. Everything it shows
comes from the server's metadata through the QQQ UI API, so the same binary works with any QQQ
application: tables, processes, reports, dashboards, records and the extensions (QBits) the server
installs. Sign in once; the CLI (`qqq <noun> <verb>`) and the terminal UI (`qqq ui`) share the profiles
and sign-ins.
**Compatibility:** servers on UI API **1.6.0 up to (not including) 2.0.0**, that is QQQ **4.1.0-RC.15 or
later**. `qqq --version` shows the range; `qqq doctor` checks a server against it.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
```
```sh
brew install qrun-io/tap/qqq # macOS, Linux
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq # Windows
npm install -g @qrun-io/qqq-cli # GitHub Packages, Node 20+, CLI only
```
Or download the binary for your platform below and check it against `SHA256SUMS`:
| Platform | File |
|---|---|
| macOS, Apple silicon | `qqq-darwin-arm64` |
| macOS, Intel | `qqq-darwin-x64` |
| Linux x64 / arm64 (glibc) | `qqq-linux-x64`, `qqq-linux-arm64` |
| Linux x64 / arm64 (musl, Alpine: `apk add libstdc++ libgcc`) | `qqq-linux-x64-musl`, `qqq-linux-arm64-musl` |
| Windows x64 | `qqq-windows-x64.exe` |
The npm package has the CLI without the terminal UI; the binaries have both.
**macOS:** these binaries are not notarized yet. A binary downloaded with a browser is stopped by
Gatekeeper; clear the flag once with `xattr -d com.apple.quarantine ./qqq-darwin-arm64`. The installer and
Homebrew do not set it. **Windows:** the `.exe` is not signed yet, so SmartScreen may warn on first run.
## Get started
```sh
qqq login --server https://your-qqq-server # device flow, browser (PKCE), Auth0, TABLE_BASED or a session
qqq home # what you can open and do
qqq table query person -w "lastName STARTS_WITH S" --order -id -l 5
qqq process run greetInteractive -r 1,2 -v greetingPrefix=Hi
qqq ui # the terminal UI on the same profile
qqq doctor # connection, sign-in, terminal, and the build for support
```
## Highlights of the terminal UI
`qqq ui` is a classic full-screen UI: a function-key bar at the bottom (F1 Help, F2 Search, F3 View,
F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit; Esc then a digit when your terminal
keeps the F-keys), Esc to go back, and a `:` command line that speaks the CLI grammar.
| Key | What it does |
|---|---|
| **F2** | **Search**: records across tables as you type (quick search), with the matched field, Enter to open, `o` for the filtered grid, Space to mark hits for actions |
| **`/`** (also `.`, Ctrl+K) | find anything: apps, tables, processes, widgets, reports, records, commands, help |
| **F9** | the menu in two levels, areas then entries (Context, Go to, Work, Connection, Settings, Help) |
| **Ctrl+O** | **Servers**: every saved profile with its short name, description and status; Enter switches |
| **F6** | the filter dialog: rows of field, operator and value, a live match count, an Advanced mode with a cheat sheet |
| **Ctrl+P** | print what you see: a record sheet, a whole table list, or any screen (printing extension) |
| **`?`** / F1 | the keyboard reference of the screen |
| **`D`** | developer view of a record or a grid: security, events, scripts, JSON, metadata, audits, APIs |
| `C` `P` `K` `T` `W`, `a` | record collaboration (collaboration extension): comments, attachments, tasks, timeline, follow, share preview, who is viewing; a strip at the bottom of the record view and, from 140 columns, a Collaboration panel |
| **Ctrl+G**, `:ask` | the **Assistant** panel: ask about your data in plain language, with the record or grid on screen as context (Ctrl+D drops it); answers stream with their numbers and links that open the record or grid |
| `:history`, Ctrl+R | everything you did in the CLI and the TUI, to do again or copy as a CLI command; Ctrl+R on the `:` line searches it like bash |
| `:sql` | SQL Runner: editor, completion popups, result grid, export, notebooks, history (sql-workbench extension) |
| F2 or Ctrl+Space in a date field | the calendar picker; dates can also be typed in your format |
| `S`, `x`, `y` | column statistics (computed from loaded rows when the server has none), export (file names carry the local date and time), copy |
| `:lock`, `:saver` | the screen saver: live tiles in one compact group, Query, the qrun.io mascot and your profile's name in block letters; everything moves and it dims, so an OLED or CRT does not burn in (`:saver burnin`, `:saver dim`) |
The mouse works everywhere (click, double-click, right click for actions, wheel, drag column edges;
F9 › Settings › Mouse or `--no-mouse` turns it off). The CLI equivalent of a grid or dialog can be copied
to the clipboard. Completion popups and text or HTML widgets (headings, status badges, aligned lists) are laid out to be read. Grids, records with forms and contingent pickers, the process wizard, bulk load,
reports and saved reports, dashboards and widgets that act, every sign-in type, themes, and a terminal
check (`qqq ui --check`) round it out.
## Highlights of the CLI
- Tables: `query`, `count`, `stats`, `get`, `create`, `copy`, `update`, `delete`, `export`, `children`,
downloads and uploads, variants, bulk insert, edit and delete from CSV or Excel.
- **`qqq assistant ask "<question>"`** streams the assistant's answer, then its numbers and the records and tables it refers to with the command that opens each (`--table`/`--record` for context, `-c` to continue, `--json`); `qqq assistant config` says whether it answers and with which model.
- **`qqq history`**: everything you did in the CLI and the TUI on the profile (`--since`, `--grep`, `--kind`, `rerun <n>`, `clear`); secrets are never written, `QQQ_HISTORY=off` turns it off.
- `qqq table stats` on a table without server column statistics computes them from the matching records (`--local`, `--no-local`, `--max-records`), and a closed pipe (`| head -1`) ends quietly.
- Default export names are `<name>_<date>_<time>.<ext>` in local time; a name you give with `-o` is used as given.
- Processes with every step screen, async jobs and cancel; reports and saved reports (pivots,
schedules); saved views; sharing; audits; widgets and dashboards as text; `home`, `find` and `search`.
- **`-o`/`--out` is always the output file and `--order` sorts** (`qqq table export person -o people.csv
--order -id`). Until 1.1, `-o` still sorts on the few commands that write no file, with a warning.
- **Profiles**: `qqq profile list|add|edit|use|remove` with a short name, a description and a sign-in mode.
- **Printing**: `qqq print <table> <id...>`, `qqq print list <table>`, `qqq print text`, `qqq print job ...`
and `qqq printer ...`. (`qqq printing ...` holds the 17 generated agent-side operations; table-list and
text printing are the processes behind `qqq print list` and `qqq print text`.)
- Extension results print as key/value rows and sub-tables; `qqq esb`, `qqq collab`, `qqq alerts`,
`qqq health` and one noun per extension; `qqq api <operationId>` calls any of the 219 UI API operations.
- JSON, table, text, CSV and YAML output with JMESPath `--query`; shell completion for bash, zsh, fish and
PowerShell; sign-ins in the OS keyring.
**Parity:** every row of the web UI's acceptance matrix that applies to a terminal passes in both the CLI
and the UI, or is blocked on a filed server issue (`docs/parity/feature-matrix.md`).
## Assistant
`Ctrl+G` (or `:ask`) in the terminal UI and `qqq assistant ask` in the CLI talk to the server's in-app
assistant: read-only, with your permissions. It needs the **assistant extension (qbit-assistant) with a model
provider configured** on the server; the owner sets the provider and its key in the server's configuration
(for the enterprise test server, `ANTHROPIC_API_KEY` in its private env file, else a local Ollama). Without the
extension the entry is disabled and says what is missing; without a provider the panel and
`qqq assistant config` show the server's reason (`NOT_CONFIGURED`) and what to configure.
## Known limitations
- Text filters ask the server for case-insensitive matching, but servers do not read that option over
the UI API yet (qqq#1358), so matching stays case-sensitive until they do.
- Jobs need a server with durable jobs. Auth0 sign-in is built but has not been tried against a real
tenant (qqq-cli#10).
- Search needs the quick search extension and printing the printing extension (qbit-printing 2.x);
without them the UI says so and offers no Print action.
- The binaries are not Developer ID signed or notarized, nor Authenticode signed. The Windows and Intel
macOS binaries are cross-compiled and were not run before this release; the macOS and Windows CI lanes
run the Node packages' build, tests and lint, not the binaries.
## For developers: the enterprise test server
`scripts/enterprise-server.sh` starts a local "enterprise" QQQ server (qqq-all's enterprise profile: every
locally runnable QBit plus the sample app) on port 8078 with mock authentication, backed by Redis,
OpenSearch and a MinIO container for object storage. Its credentials and state live in a private
directory under `~/.local/state/qqq-enterprise`. `--status`, `--stop` and `--log` manage it, and
`scripts/enterprise-smoke.sh` sweeps the CLI over every QBit surface. `scripts/print-agent.sh` runs a
virtual print agent for the printing extension. The tmux smoke test for the UI takes `--server URL` and
`--profile NAME`: `packages/tui/scripts/smoke.sh`.
Full list of changes: [CHANGELOG.md](https://github.com/QRun-IO/qqq-cli/blob/main/CHANGELOG.md).
+240
View File
@@ -0,0 +1,240 @@
# Release runbook: qqq 1.0.0
From "the branch is green" to "1.0.0 is published and installable everywhere". It is written for
1.0.0 but holds for any release; replace the versions. Nothing in the repository tags, pushes or
publishes by itself: `scripts/release.mjs` checks and prints, people run the commands.
What ships:
| Artifact | Version for 1.0 | Where | Made by |
|---|---|---|---|
| `qqq` binaries with the terminal UI, 7 targets, plus `SHA256SUMS` | 1.0.0 | GitHub release `cli-v1.0.0` | `scripts/build-binaries.mjs` (via `release.mjs`) |
| `@qrun-io/qqq-cli` (CLI only, Node 20+) | 1.0.0 | GitHub Packages | the orb, on tag `v1.8.0` |
| `@qrun-io/qqq-core` | 1.0.0 | GitHub Packages | the orb (with PR #165), else by hand |
| `@qrun-io/qqq-ui-api-client` | 1.8.0 | GitHub Packages | the orb |
| `@qrun-io/qqq-ui-api-conformance` | 1.6.0 | GitHub Packages | the orb |
**All four npm packages go final together.** The orb (PR #165) derives the dist-tag from all four
versions: if any of client, core, conformance or CLI has a prerelease part, all four are published as
`rc`; only when all four are final do they go out as `latest`. So 1.0.0 ships client 1.8.0 and
conformance 1.6.0 as finals beside CLI and core 1.0.0, under the tags `v1.8.0` (orb publish) and
`cli-v1.0.0` (GitHub release). `release.mjs` refuses a final CLI version while any of the four is still a
prerelease (the readiness gate's "package versions" line).
| Homebrew formula `qqq` | 1.0.0 | `QRun-IO/homebrew-tap` | `scripts/update-packaging.mjs`, then a PR |
| Scoop manifest `qqq` | 1.0.0 | `QRun-IO/scoop-bucket` | `scripts/update-packaging.mjs`, then a PR |
## Checklist: what needs James
Nobody else can do these; each says what happens without it.
- [x] **Orb changes released** in `kingsrook/qqq-orb` 0.8.33 (`docs/ci-lanes.md`): publishing
`@qrun-io/qqq-core`, lint, the `operations.ts` drift check, the dist-tag rule above (PR #165), and
the macOS arm64 and Windows x64 lanes, which `.qqq-ci.yml` turns on (`platforms:`) and a release tag
requires before publishing.
- [ ] **CircleCI plan** includes the `m4pro.medium` (macOS) and `windows.medium` resource classes. Without
them the platform lanes cannot start, and because a release tag requires them, the publish waits;
remove `platforms:` from `.qqq-ci.yml` to publish without them.
- [ ] **Apple Developer ID and notarization**: a "Developer ID Application" certificate and a notary
credential (App Store Connect API key) on the release machine. Without it the macOS binaries are
only ad-hoc signed. Homebrew and the curl installer are not affected (they do not set the
quarantine flag), but a binary downloaded with a browser is stopped by Gatekeeper ("Apple cannot
check it for malicious software"). Workaround, documented in the README and the packaging notes:
`xattr -d com.apple.quarantine ./qqq-darwin-arm64`, or Finder, right-click, Open.
- [ ] **Windows Authenticode certificate** (the KofTwentyTwo release standard). Without it SmartScreen
warns on first run ("Windows protected your PC", More info, Run anyway); Scoop installs still work.
- [x] **`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` exist** (created from `docs/packaging/homebrew-tap/` and
`docs/packaging/qqq.json`). After the GitHub release, run `scripts/update-packaging.mjs` on the uploaded
`SHA256SUMS` and commit the filled `Formula/qqq.rb` and `bucket/qqq.json` to them. Without that commit
`brew install` and `scoop install` fail their checksum (the curl installer and the download still work).
- [ ] **CircleCI context** `qqq-maven-registry-credentials` holds a `GITHUB_TOKEN` that can write the
four `@qrun-io` packages (the orb checks it authenticates; write access is only proven by a publish).
- [ ] **Go for 1.0.0 final**: the 1.0.0-rc.1 feedback is accepted and no blocker is open.
## Not verified before 1.0
The platform lanes run the Node packages' build, tests and lint on macOS arm64 and Windows x64 (Node
20), which covers the keyring code paths there, but they do not build or run the binaries.
- `qqq-windows-x64.exe` (CLI and UI) has never run; `scripts/verify-binary.ps1` is the smoke to run on
a Windows machine.
- `qqq-darwin-x64` has never run (no Intel Mac or Rosetta on the build machine).
- Windows Terminal rendering of `qqq ui`, and the DPAPI keyring outside unit tests.
Verified on the build machine: darwin-arm64 (CLI, keyring, completion, `qqq ui` tmux smoke and width
smoke against the sample server), linux-x64 glibc, linux-arm64 glibc and linux-arm64-musl in Docker
(CLI and `qqq ui` in tmux), the npm tarballs installed on Node 20, the Homebrew formula in the
`homebrew/brew` container, and the curl installer from a local mirror.
## 1. Branch green
- `feat/m5-cli-release` (and whatever else is in the release) is merged to `main`; the orb pipeline on
`main` is green: `cli_verify_publish` (Linux, `cimg/node:22.23`) and the `cli-macos-arm64` and
`cli-windows-x64` lanes.
- Locally on `main`, clean tree: `npm ci && npm run build && npm test && npm run lint && npm run test:tui`.
## 2. Versions: release candidate to final
**For 1.0.0 this step is done** on the branch `release/1.0.0` (34bbd2a, on top of the rc.1 commit
6517373): all four packages final, the exact workspace pins, the `## [1.0.0]` CHANGELOG entry,
`docs/release-notes/1.0.0.md`, and the READMEs at 1.0.0. That branch (with the release docs merged into
it) is fast-forwarded onto `main`; nothing else changes versions:
```sh
git checkout main && git pull --ff-only
git merge --ff-only release/1.0.0 # fails if main moved: rebase release/1.0.0 onto main first
git push origin main
```
For later releases, the table below is the recipe.
On a branch `release/1.0.0` from `main`:
| File | Change |
|---|---|
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | `1.0.0-rc.N` → `1.0.0` |
| `packages/core/package.json` | `1.0.0-rc.N` → `1.0.0` |
| `packages/ui-api-client/package.json` | `1.8.0-rc.N` → `1.8.0` (the client follows the UI API version) |
| `packages/conformance/package.json` | `1.6.0-rc.N` → `1.6.0` |
| workspace dependencies in `packages/cli` and `packages/core` | the same versions, exact |
| `CHANGELOG.md` | a `## [1.0.0] - <date>` entry above the rc entries |
| `README.md`, `packages/cli/README.md` | the version and `@rc` in the npm command (`@latest`, or no tag) |
All four packages move to finals in the same commit; none may stay `-rc` (the orb would then publish all
four as `rc`, and `release.mjs` refuses). Then `npm install` (refreshes `package-lock.json`),
`npm run lint`, which fails if any version disagrees, and `npm run release -- --check`, which must say
READY (parity 100% in both columns, every finding filed, the CHANGELOG entry, documented commands,
package versions). Commit (`git commit -S -m "release: 1.0.0"`), merge to `main` through a pull request.
## 3. Release check and binaries
On `main` at the release commit, clean tree:
```sh
node scripts/release.mjs # the readiness gate (final versions), npm ci, generate drift, build, test,
# lint, TUI tests and type check, pack dry runs, the 7 binaries with the UI
# and dist/SHA256SUMS
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # sample server running
```
`release.mjs` prints the commands for steps 5 and 6 with the right versions.
## 4. Sign (James's credentials)
Signing changes the files, so it comes before the checksums are final. Each script reads its
credentials from the environment, does nothing (and says what is missing) without them, has
`--dry-run`, and recomputes `dist/SHA256SUMS`.
```sh
QQQ_MACOS_SIGN_IDENTITY="Developer ID Application: <name> (<team>)" QQQ_NOTARY_PROFILE=qqq-notary \
scripts/sign-macos.sh # codesign, hardened runtime + scripts/macos-entitlements.plist, notarytool --wait
QQQ_WINDOWS_PFX=<cert.pfx> QQQ_WINDOWS_PFX_PASSWORD_FILE=<file> \
scripts/sign-windows.sh # osslsigncode with an RFC 3161 timestamp
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # still runs after signing
spctl --assess --type execute -vv dist/qqq-darwin-arm64 # "accepted, source=Notarized Developer ID"
```
A bare executable cannot be stapled: Gatekeeper fetches the notarization ticket online the first time a
quarantined (browser-downloaded) copy runs, and refuses offline until it can. Homebrew and the curl
installer do not quarantine, so they work signed or not. Tried here without real credentials: an ad-hoc
hardened-runtime signature with the entitlements (the CLI and the `qqq ui` smoke still pass), and an
osslsigncode signature with a throwaway certificate that verifies. A real Developer ID, notarization
and an Authenticode certificate are untried until James's credentials are used.
## 5. Tags and packages
```sh
git tag -s v1.8.0 -m "UI API client 1.8.0, qqq 1.0.0" # the orb publishes on v<client version>
git tag -s cli-v1.0.0 -m "qqq 1.0.0" # the GitHub release with the binaries
git push origin v1.8.0 cli-v1.0.0
```
The tag `v1.8.0` makes the orb's `cli` profile verify the commit again and publish client, core,
conformance and CLI to GitHub Packages, all as `latest` because all four are final. Watch the
`publish_release` workflow. Only if the orb release with PR #165 is not out yet, finish by hand:
```sh
npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag latest
for p in qqq-ui-api-client qqq-core qqq-ui-api-conformance qqq-cli; do
npm dist-tag add "@qrun-io/$p@<its version>" latest --registry https://npm.pkg.github.com
done
```
A version can never be published twice: if the orb job fails after publishing some packages, bump the
patch version of the rest instead of retrying the same one.
## 6. GitHub release
```sh
gh release create cli-v1.0.0 --repo QRun-IO/qqq-cli --latest --title "qqq 1.0.0" \
--notes-file <(sed -n '/^## \[1\.0\.0\]/,/^## \[/p' CHANGELOG.md | sed '$d') \
dist/qqq-* dist/SHA256SUMS
```
`dist/qqq-*` is the seven binaries (source maps are in `dist/sourcemaps/`, not uploaded). For a release
candidate use `--prerelease` instead of `--latest`. The curl installer takes the newest stable `cli-v*`
release (the channel Homebrew and Scoop follow); a release candidate is reached with `--pre` or
`--version 1.x.y-rc.N`.
## 7. Homebrew tap and Scoop bucket
```sh
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
```
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
their checks.
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
CI fetches every platform's download, installs the formula and runs its test (which insists on
`binary with ui`). Scoop's `checkver`/`autoupdate` can take later versions from `SHA256SUMS` by itself.
## 8. Smoke after publishing
Against a real server (`--server`), from clean machines or containers:
```sh
# curl installer (macOS, Linux)
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
scripts/verify-binary.sh ~/.local/bin/qqq --server https://<server>
# Homebrew
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq && scripts/verify-binary.sh "$(brew --prefix)/bin/qqq"
# npm, Node 20
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@1.0.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
# Docker (glibc and musl)
docker run --rm --platform linux/amd64 -v "$PWD/dist:/d:ro" -v "$PWD/scripts:/s:ro" debian:bookworm-slim bash /s/verify-binary.sh /d/qqq-linux-x64
```
```powershell
# Windows, PowerShell
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq
pwsh scripts\verify-binary.ps1 -Binary (Get-Command qqq).Source -Server https://<server>
```
And by hand once: download `qqq-darwin-arm64` with a browser on a Mac and run it (notarized: it runs;
not notarized: the Gatekeeper prompt, cleared by `xattr -d com.apple.quarantine`), then `qqq ui` in
Windows Terminal.
## 9. After
- Merge `main` back into the development branch; the next development version can stay at 1.0.0 until
the next bump (lint keeps the versions consistent either way).
- Announce with the release link and the install commands from the README.
## When something goes wrong
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` and publish a patch; a
version cannot be unpublished from GitHub Packages without breaking installs that pinned it.
- **A binary is broken**: delete the asset from the GitHub release, rebuild, re-sign, upload, recompute
`SHA256SUMS` and rerun step 7 (the formula and manifest carry the sums).
- **The tap's CI fails on the new formula**: do not merge its pull request; `brew install` keeps
serving the previous version.
+80
View File
@@ -0,0 +1,80 @@
# CLI and kernel review, 2026-10-31
A fresh read of `packages/core/src` and `packages/cli/src` (generated `schema.ts` and `operations.ts` skipped) after
the five M1 streams were merged onto the 0.1 CLI (`feat/m1-kernel-cli` at f364a3c). Branch `feat/m1-hardening`.
Every finding below was reproduced (against the sample server at :8077, or a test) before it was changed; each fix
has a test (unit test, or a case in the golden suite `packages/cli/src/test/cli.test.ts`).
Severity: **high** = wrong data, lost state or a command that silently does something else; **medium** = wrong exit
code, hidden error or an inconsistency a user meets; **low** = polish.
## Findings fixed
| # | Sev | Finding | What changed | Test |
|---|---|---|---|---|
| 1 | high | Short options collided with global ones. The global `-s` (`--server`) took every `-s` meant as `--set` (`table create pet -s name=Rex` failed with `Error: Invalid URL`); the global `-p` (`--profile`) took `qqq api ... -p tableName=person` and every generated extension verb's `-p` (`No profile "tableName=person"`); commander's `-V` (`--version`) took `table query -V <view>`, printed the version and exited 0. | `--server` and `--path` have no short form; the version flag is `--version` only. `collab presence --watch` lost `-w` (it is `--where` everywhere else); `saved-view save` takes `-l, --limit` (hidden `--page-size` kept). | golden `create`, `query-view-short`, `--version` |
| 2 | high | `table update` without `-i` read nothing of the record, so a contingent field could not be changed (`City: set State / province first` on a record that has a state) and possible values were not narrowed by the record's dependencies. A changed dependency cleared its dependents locally but did not send the clear. | update always starts from the record's values; dependents cleared by a changed field are sent (`null`) and named in a note. | golden `update-contingent`, `update-clears` |
| 3 | high | `readConfig` treated an unparsable `config.json` as empty, and the next profile write saved that: one stray comma lost every profile. `FileStore`/`DpapiFileStore` did the same to `secrets.json` (saving one sign-in wiped the others). `readConfig` also dropped unknown sections. | Both throw (`ConfigFileError`, `SecretFileError`) and change nothing; unknown sections are kept. | core `profiles.test`, golden "corrupt config.json" |
| 4 | high | The 0.1 credentials import fired keyring writes without awaiting them (`void secrets().set(...)`) and renamed `credentials.json` immediately: a keychain that refused the write lost the sign-in, and the rejection was unhandled. | The import is awaited before any command runs; the file is renamed only after every write succeeded, else it stays with a warning. | golden "0.1 sign-ins are imported" |
| 5 | medium | `getLogin` turned an unreadable stored sign-in into "not signed in", which surfaced as an unexplained 401. | It says the sign-in is unreadable and how to replace it; `logout` still removes it. | (path covered by logout; message reviewed) |
| 6 | medium | `Context.confirm` and the variant chooser opened their own readline without a SIGINT listener: Ctrl-C at `Delete? [y/N]` failed with a bare AbortError (`Error: The operation was aborted`, exit 1); Ctrl-D at any question did the same. | All prompts go through `prompt.ask`: Ctrl-C is passed on as SIGINT (exit 130, or the run's cancel), Ctrl-D is a usage error. `ask` takes an input stream for tests. | `prompt.test` |
| 7 | medium | `tail` stopped with Ctrl-C printed `Error: This operation was aborted` and exited 1. | Quiet stop, exit 130; event times in the local zone. | live check |
| 8 | medium | Answering `n` to `Submit?` in `process run` exited 130 with the cancel text, as if interrupted. | `Not submitted; ...` and exit 0, as `table delete` does for "Not deleted." (the run is still cancelled on the server). | reviewed; prompt path in `prompt.test` |
| 9 | medium | `table get --associations` swallowed every error reading a child table's metadata (`catch {}`): a server down or a 500 showed as "unavailable". | Only 403/404 mean unavailable; anything else fails the command. | reviewed |
| 10 | medium | Record values were rendered three ways: the record view (adornments), `shared.valueText` (query rows) and a duplicate `valueOf` (child lists). Query and child-list cells showed REVEAL values in the clear. | Kernel `valueText` has a cell mode (same adornment rules, no link targets, hints or tooltips); query rows, child lists and joined fields use it; `valueOf` is gone; `--reveal` on `table query` and `table children`. | core `record-view.test`, cli `shared.test` |
| 11 | medium | DATE_TIME display was applied in records but not elsewhere: audits, collab comments and people, ESB counters and messages, and job logs sliced UTC text (`String(x).slice(0, 19)`). | All go through `displayDateTime` (`instantText`): the local zone, `--utc` for raw. | golden `esb-overview`, `query-utc`, `query-csv` |
| 12 | medium | Three help extractors (`helpContentText` for `help show`, `helpEntryText` in the record view, `helpText` on process screens) disagreed: an empty `contentAsText` hid process help, MARKDOWN came out differently, and `table get --help-content` and `process describe` printed something other than `help show`. | `helpContentText` is the rule; the others use it; `--help-content` (table level) and `process describe` print `helpToText` of the same entries `help show` uses. | core `record-view.test` ("one help text rule") |
| 13 | medium | Downloads were written four ways (`saveDownload`, raw `writeFileSync` in `widget --csv` and `api --out`, ad hoc `-` handling). | One `writeDownload`: `-` is stdout, a named path is written as given, a default name never replaces a file and goes into the download directory when set (see "Requested additions"). | golden `export-download-dir`, `widget-csv` |
| 14 | medium | Numeric options were passed through `Number()` unchecked: `-l many` sent `limit: null`, `--timeout soon` sent `NaN`. | `intOption`/`positiveInt` in every command (exit 2 before any request). | golden `bad-limit`, `process-run-bad-timeout` |
| 15 | medium | Missing files named by `process run --file`, `api --form @file`, `api --bytes` or a JSON file argument (`--input-json f.json`) failed with a raw `ENOENT` (exit 1). | `readFileArg`: usage error (exit 2) naming the flag. | reviewed (same helper as `--upload`) |
| 16 | medium | Exit codes: a 502/503/504 exited 70 (server error) though it means "try again"; `qqq api` with a missing path parameter exited 1; a fetch failure worded differently by the runtime (`bad port`) exited 1. | 502/503/504 exit 4; `OperationInputError` exits 2; network TypeErrors are matched on message and cause. | golden `exit-503`, `api-missing-path`; `shared.test` |
| 17 | low | `callOperation` threw a SyntaxError on an empty body with a JSON content type (204-like answers). | Empty body is no data. | reviewed |
| 18 | low | `runProcess` stopped after 200 steps and returned as if the run had completed. | It throws a `ProcessError` naming the step. | core `runner.test` |
| 19 | low | Footers differed per command ("(1 results)", "Pet of X: 3-4 of 8", "(3 values)"). | `countFooter`: `(N records; ...)` with plurals everywhere (query, children, stats, audits, search, help-search, saved views, jobs, ESB, collab). | golden `query-table` and others |
| 20 | low | `--variant` was missing on `table children` and `table download`; child lists of a variant table were read without it. | Both take `--variant`; `fetchChildren` sends it for child tables on a variant backend. | reviewed (sample has no variants) |
## Requested additions (coordinator)
- **Download directory.** `qqq config set|get download-dir`, `upload-dir`, `download-mode`, `theme`, kept in the
`ui` section of `~/.config/qqq/config.json` through the TUI's kernel helpers (see "Merge with the TUI stream").
`table download`,
`table export`, `report run` and process downloads default to it (created when missing) when no `--out` /
`--out-dir` is given. An explicit `widget get --csv <file>` is written where named (a named path is never moved).
- **`qqq doctor`.** Profile, reachability, UI API version range (1.6.0 to 1.8.0, major from `supportedMajorVersions`),
authentication type, sign-in accepted, extensions and minimums, keyring, cache age; `--conformance` runs the
conformance package's read-only checks. Exit 1 lists the failures. The terminal half is the kernel's
`checkEnvironment`/`formatReport` (see "Merge with the TUI stream").
## Merge with the TUI stream (feat/m1-kernel-cli at 165c002)
One line per decision taken while merging the base into this branch:
- `main.ts` is the base's thin entry over `program.ts`; it still awaits `importLegacyCredentials()` before parsing (finding 4).
- `program.ts` (`createProgram`) carries the option fixes of finding 1 (`--version` only, `--server` without `-s`) and registers `qqq doctor`.
- `config.json` `ui` section: the TUI's names win, one set of helpers. My `readUiSettings`/`writeUiSettings` and their `defaultDownloadDir`/`defaultUploadDir`/`downloadPrompt` keys are gone; the CLI reads and writes `ui.files` through `readFilePreferences`/`writeFilePreferences` (files/navigator.ts) and the theme through `readTheme`/`writeTheme`. `download-prompt` became `download-mode ask|silent`, the TUI's setting; `qqq config set|get theme` was added (`--profile` sets one profile's theme).
- `readConfig` keeps the base's shape (profiles, `ui` with theme, views, files) plus finding 3: a corrupt file is refused (`ConfigFileError`) and unknown sections are kept. The profiles test now exercises files, theme and an unknown section together.
- `qqq doctor`'s terminal rows come from `checkEnvironment`, with `formatReport` printed under the table; `--fix` asks before each `applyLiveFix` (`--yes` answers those) and calls `appendTmuxConf` only after a y typed at a terminal (`--yes` never does).
- Prompts and `runCli` coexist: `runCli` sets `stdin.isTTY` false, so `prompt.ask`, `confirm` and the variant chooser refuse with a usage error in the TUI's `:` pane instead of opening readline; output written straight to `process.stdout` (`--out -`) is captured by `runCli`. A test runs `runCli` in a child process to prove both.
- `@qrun-io/qqq-ui-api-conformance` is a dependency of the CLI package (for `doctor --conformance`); the release agent sets the published version.
- `docs/parity/feature-matrix.md` was regenerated from the merged `scripts/parity-matrix.py` (the base's TUI column plus this branch's REC-022, REC-033 and PRC-014 notes).
## Found and left
| Sev | Finding | Why left / suggestion |
|---|---|---|
| medium | The macOS Keychain store passes the secret on `security add-generic-password -w <secret>`'s command line (visible to `ps` for an instant); the DPAPI store does the same with PowerShell. | Needs `security -i` over stdin (quoting) or a native helper; worth a follow-up before GA. |
| medium | The metadata cache is keyed by server, not by profile or user: switching profiles on one server shows the previous user's permission-filtered metadata for up to 5 minutes (a refused token still lists cached tables). | Key the cache by profile, or clear it on login/logout. Touches the cache format the TUI reads. |
| low | `runProcess` returns when it reaches the last frontend step, so a last step with an input form would never be submitted. No sample process has one. | Confirm the server's contract for a final input step before changing it. |
| low | `apps show` and `saved-view list` show `?` for a count that failed, without the reason. | Display-only; acceptable, but could print the error on stderr. |
| low | Remaining short-option overlaps without a collision: `-o` is `--order` on queries and `--out` on download/report/api; `-f` is `--fields` and `job logs --follow`; `-i` is `--interactive` and `widget get --input`. | No command has both meanings; renaming would break documented usage. |
| low | `collab presence --watch` exits 0 on Ctrl-C while `tail` exits 130. | Presence leaves its lease cleanly, so 0 is defensible; align if `tail`'s 130 is the house rule. |
## Verified
- `npm run build`, `npm test` after the merge (ui-api-client 17, core 109, conformance 3, cli 68 incl. the golden suite in about 5 s) and `npm run test:tui` (9) and `npm run lint`.
- Every `qqq` command block in `TRY-IT.md` run against the sample server (UI API 1.8.0, MOCK) from this worktree,
except the sign-in lines and the OAuth (8078) and collaboration (8098) servers, which need servers this review did
not start. Commands that need a terminal (`-i`, delete without `--yes`, the validation choice) refuse with exit 2
as documented. The guide was corrected where it was wrong: placeholders run as commands (`[field]`,
`rename|delete`, `cache show | refresh | clear`), the upload example's missing files, and record ids that the
shared sample data no longer matches.
+35
View File
@@ -0,0 +1,35 @@
# UI API server findings from the terminal clients
Observed while building the CLI and TUI against QQQ 4.1.0-SNAPSHOT (`develop` 6ac64fc66, UI API 1.8.0) with
the sample app and MOCK authentication. Filed on 2026-10-31 (James's OK); the `Issue` column links each one. Finding 10 was already open as #784, 9 and 21 share #1343, 18 is in qbit-collaboration.
| # | Finding | How to reproduce | Client workaround | Issue |
|---|---|---|---|---|
| 1 | A bearer session UUID cannot continue an asynchronous process step: the job fails with `Permission denied for process state`. The same UUID as the `sessionUUID` cookie works. | `POST /manageSession` (MOCK), then `init` and `step` `sleepInteractive` with `stepTimeoutMillis=0` using `Authorization: Bearer {uuid}`; poll status. | Session sign-ins are sent as a cookie (`sessionCookie` option of the client). | [qqq#1335](https://github.com/QRun-IO/qqq/issues/1335) |
| 2 | Process `init` and `step` silently ignore a url-encoded `values` JSON field; only `multipart/form-data` is read. No 400. | `curl -d 'values={"sleepMillis":4000}'` runs with the default 1000 ms. | Clients always send multipart. A 415 or 400 would help. | [qqq#1336](https://github.com/QRun-IO/qqq/issues/1336) |
| 3 | `GET /jobs` answers 404 although `/version` lists `durableJobs` in `features`. | Sample server, `qqq job list`. | The CLI explains; the TUI footer shows `jobs n/a`. Either drop the feature flag when no job store exists, or serve an empty list. | [qqq#1337](https://github.com/QRun-IO/qqq/issues/1337) |
| 4 | `searchTablePossibleValues` with `ids: ["Narnia"]` on an INTEGER possible-value field returns 500 `QException (Error executing query)`. | `qqq api searchTablePossibleValues --path tableName=placeAddress --path fieldName=countryId --body-json '{"ids":["Narnia"]}'` | Clients only send ids that can parse for the field type. A 400 with `fieldErrors` would be right. | [qqq#1338](https://github.com/QRun-IO/qqq/issues/1338) |
| 5 | `searchTablePossibleValues` with `searchTerm` matches case-sensitively (or by prefix) on this source: `can` does not find `Canada`. | Same table, `{"searchTerm":"can"}` on `countryId`. | Clients fetch the first page and match client-side when the search returns nothing. Document the matching rule in the spec. | [qqq#1339](https://github.com/QRun-IO/qqq/issues/1339) |
| 6 | `GET /table/{t}/{pk}` returns `{ record: {...} }` while `query` returns `{ records: [...] }` with bare records; the design doc and the 0.1 CLI expected a bare record. | Any record. | Clients unwrap both. Worth a note in `UI-API-CLIENTS.md`. | [qqq#1340](https://github.com/QRun-IO/qqq/issues/1340) |
| 7 | Process `init` on `clonePeople` without records answers `ERROR` with `No records were selected…` only after `init`; `getProcessMetaData` says `minInputRecords` 0. | `qqq process run clonePeople`. | Clients show the message. The metadata could carry the real minimum. | [qqq#1341](https://github.com/QRun-IO/qqq/issues/1341) |
| 8 | `manageSession` has no documented username and password fields for `TABLE_BASED`, so a native client cannot sign in to such an app without the browser. | Spec `manageSession` request body. | `qqq login` refuses TABLE_BASED with a message. The design doc's nice-to-have "documented TABLE_BASED bearer recipe". | [qqq#1342](https://github.com/QRun-IO/qqq/issues/1342) |
| 9 | `searchRecords` returned no results for `Avery` although `person` has search fields; unclear whether the sample app configures a search provider. | `qqq search Avery`. | None; needs a look at the sample's search setup. | [qqq#1343](https://github.com/QRun-IO/qqq/issues/1343) |
| 10 | The sample app configures no uploaded-file archive table (`QJavalinMetaData.uploadedFileArchiveTableName`), so every bulk-insert `upload` step and `POST /uploads` fail with `UploadFileArchiveTableName was not specified in javalinMetaData`. Bulk load cannot be exercised against the sample. | `qqq api initProcess --path processName=person.bulkInsert --form 'values={"tableName":"person"}'` then step `upload` with a file. | None in the client. The sample needs an archive table (memory or filesystem backend) and the javalin setting; the Next UI acceptance fixtures may already have one. | [qqq#784](https://github.com/QRun-IO/qqq/issues/784) |
| 11 | **By design; clients send CASE_INSENSITIVE.** `CONTAINS`, `STARTS_WITH` and `ENDS_WITH` match case-sensitively on memory, H2 and PostgreSQL (MySQL only looks insensitive through collation); the opt-in is the criteria option `CriteriaOption.CASE_INSENSITIVE` on each criterion (qqq ruling, 2026-10-31). | `qqq table query person -w "lastName ~ sample"` | The kernel grammar and quick search add `options: ["CASE_INSENSITIVE"]` to text matches by default (`~=`, `OPERATOR:CS` or `--case-sensitive` ask for a case-sensitive match). Over v1 the option does not arrive yet: finding 27. | [qqq#1344](https://github.com/QRun-IO/qqq/issues/1344) |
| 12 | `POST /table/{t}/export` answers 500 `INTERNAL_ERROR` for an unsupported `format` (`Unsupported report format: pdf.`) and for an unknown name in `fieldNames` (`Field name bogus was not found on the Person table.`). Both are request errors. | `qqq api exportTable --path tableName=person --body-json '{"format":"pdf"}'`; same with `"fieldNames":["bogus"]`. | `qqq table export` checks the format and the field names before sending (exit 2). A 400 with `fieldErrors` would be right. | [qqq#1345](https://github.com/QRun-IO/qqq/issues/1345) |
| 13 | `POST /processes/{p}/{uuid}/cancel` answers `200 {}` for a run that declares no cancel step, and the run can still be stepped afterwards (its state is kept). A malformed UUID answers 500 `INTERNAL_ERROR` (`IllegalArgumentException`) instead of 400. | init `sleepInteractive`, cancel, then step `screen0`: it runs. `POST …/nonexistent-uuid/cancel`. | `qqq process run` treats 200 as cancelled and stops; nothing to do for the 500. A cancelled run should refuse further steps (409 or 404). | [qqq#1346](https://github.com/QRun-IO/qqq/issues/1346) |
| 14 | The `isStepBack=true` query parameter of `stepProcess` (going back to `backStep`) is described in the `ProcessStepComplete.backStep` schema text but is not a declared parameter of the operation, so a spec-driven client cannot know it exists. | Spec: `stepProcess` lists only `tableVariant`. | The kernel sends `isStepBack=true` as the Next UI does. Declare the parameter. | [qqq#1347](https://github.com/QRun-IO/qqq/issues/1347) |
| 15 | A step that fails on missing input answers `ERROR` with only `error` (`Error message: Unable to get Cities by state. Missing required input.`), no `userFacingError`, and the field is not marked required in the step metadata, so a client cannot tell an input problem from a crash or ask for the field first. | `qqq process run placeAddressPick --no-prompt`. | The CLI exits 6 (validation) when the message says required input is missing or the error is user-facing, 1 otherwise. Mark the fields required, or return `userFacingError` (or a 400 with `fieldErrors`). | [qqq#1348](https://github.com/QRun-IO/qqq/issues/1348) |
| 16 | `downloadRecordFieldFile` on a BLOB field without adornments answers 500 `NullPointerException (Cannot invoke "java.util.List.stream()" because the return value of "QFieldMetaData.getAdornments()" is null)`. | `qqq table create fieldLab --set name=x --upload blobValue=./a.txt`, then `qqq api downloadRecordFieldFile --path tableName=fieldLab --path primaryKey=<id> --path fieldName=blobValue --path filename=a.txt -o out`. | `qqq table download` uses the route only for FILE_DOWNLOAD fields and decodes the record's base64 BLOB value otherwise. The route should treat no adornments as none (or answer 404/400). | [qqq#1349](https://github.com/QRun-IO/qqq/issues/1349) |
| 17 | The sample app declares no field adornments other than LINK, no hidden fields or sections, no help content, no exposed joins, no composite or aliased associations and no table the mock user may not read, so CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP, help display, denied child reads and composite associations can only be checked with unit tests against the client. | `GET /metaData/table/{t}` for every sample table. | None needed in the client; a `fieldLab`-style adornment table in the sample (one field per adornment, a hidden section, help on a field, section and table, a composite association) would make these rows checkable live. | [qqq#1350](https://github.com/QRun-IO/qqq/issues/1350) |
| 18 | qbit-collaboration 1.1.0: when the last viewer leaves, `presence.changed` (and the table snapshot) is `{"table":"person"}` with no `viewers`, although `CollaborationPresenceSnapshotV1.viewers` is a non-nullable array. A client that waits for `viewers` keeps showing the last person. Seen on the qbit's `CollaborationDevServer` (QQQ 4.1.0-RC.15). | Two sessions (`?user=bob` and alice); bob POSTs then DELETEs `/qbit/collaboration/presence` while alice is subscribed to `table:person`. | The kernel treats a payload with `table` as the snapshot and missing `viewers` as none. The qbit should send `"viewers": []`. | [qbit-collaboration#52](https://github.com/QRun-IO/qbit-collaboration/issues/52) |
| 19 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. | [qqq#1351](https://github.com/QRun-IO/qqq/issues/1351) |
| 20 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. | [qqq#1352](https://github.com/QRun-IO/qqq/issues/1352) |
| 21 | `GET /version` lists the `recordSearch` feature, but `POST /search` on the sample answers `{"results":[],"tables":[],"provider":{"status":"NONE"}}` for any text: the feature is advertised with no provider behind it, so a client cannot tell "nothing matched" from "this server cannot search" without reading `provider.status`. | `qqq api searchRecords --body-json '{"query":"avery"}'` | The TUI palette reads `provider.status` and says the server has no record search. Either list `recordSearch` only when a provider is configured, or document `provider.status` as the signal. | [qqq#1343](https://github.com/QRun-IO/qqq/issues/1343) |
| 22 | The `multiStatistics` widget payload names its list `statisticList` (singular) inside `statisticsGroupData`, while other statistics payloads and the kernel's first reading used `statisticsList`; the OpenAPI document does not type the group, so a client can only guess. | `qqq widget get SampleMultiStatisticsWidget --output json` | The kernel render model accepts both names. Type `MultiStatisticsData` group items in the spec. | [qqq#1353](https://github.com/QRun-IO/qqq/issues/1353) |
| 23 | The realtime SSE writer (`RealtimeStreamSpecV1.handleOutput`) serialises event data with `JsonUtils.toJson`, whose mapper is `NON_EMPTY`, so every empty array is dropped from every event while the HTTP JSON endpoints keep them (`ALWAYS`). Root cause of finding 18; also hits an empty `fields` on `record.*` events. | Subscribe to `/realtime/stream`; emit an event whose data has an empty list; the key is missing. | The kernel treats a missing array in an event as empty. Fixed in qbit-collaboration#52 for presence; core should serialise with `ALWAYS`. | [qqq#1354](https://github.com/QRun-IO/qqq/issues/1354) |
| 24 | A weekday criterion (`fieldFunction` `WeekdayOfDate`, as the web UI sends for "day of week is any of") fails on the sample with 500 `INTERNAL_ERROR` (`Error executing query`): the RDBMS backend renders it as MySQL `WEEKDAY(col) + 1`, and H2 has no `WEEKDAY` function (`JdbcSQLSyntaxErrorException: Function "WEEKDAY" not found`). | `POST /qqq/v1/table/person/query` with `{"filter":{"criteria":[{"fieldName":"birthDate","operator":"IN","values":["1"],"fieldFunction":{"fieldName":"birthDate","functionTypeIdentifierName":"WeekdayOfDate"}}],"booleanOperator":"AND"}}`; `qqq ui`, `:filter weekday(birthDate) IN mon`. | The TUI sends the criterion as the web UI does and shows the server error in the grid. The backend needs a per-dialect weekday expression (H2 `ISO_DAY_OF_WEEK`, MySQL `WEEKDAY()+1`). | [qqq#1355](https://github.com/QRun-IO/qqq/issues/1355) |
| 25 | A JSON `null` in a process step's `values` answers 500 `QRuntimeException (Non-serializable value in param map under key [values][birthDate]: Null)`. Bulk edit clears a switched-on field by sending it empty, and `null` is the natural JSON for "no value". | `qqq api stepProcess` on `person.bulkEdit` step `edit` with `values={"birthDate":null,"bulkEditEnabledFields":"birthDate"}`. | The kernel sends `""` for a cleared field, as the Next UI does (`bulkEditValues`). Treat `null` as no value, or answer 400. | [qqq#1356](https://github.com/QRun-IO/qqq/issues/1356) |
| 26 | The bulk-insert mapping suggester (`suggestedBulkLoadProfile`) maps one file column to a main-table field and to a child-table field with the same label (`Birth Date` to `birthDate` and `pets.birthDate`) with layout `TALL`, so submitting the suggestion unchanged builds child records whose required fields are unmapped; the suggestion also carries no `version` while `bulkLoadProfile` has `"version":"v1"`. | Bulk insert a CSV with First Name, Last Name, Email, Birth Date into `person`; read the `upload` step's `suggestedBulkLoadProfile`. | The kernel (`cleanSuggestion`) drops a child field that reuses a column a main field reads and uses FLAT when no child field is left. | [qqq#1357](https://github.com/QRun-IO/qqq/issues/1357) |
| 27 | The `CASE_INSENSITIVE` criteria option (finding 11's opt-in) cannot be sent over v1: `QFilterCriteriaDeserializer` reads `fieldName`, `operator`, `values` and `fieldFunction` but not `options`, and the spec's criterion schema does not declare `options`. A query with `"options":["CASE_INSENSITIVE"]` answers as if it were absent (no error). | `qqq api queryTable --path tableName=person -b '{"filter":{"criteria":[{"fieldName":"lastName","operator":"CONTAINS","values":["sample"],"options":["CASE_INSENSITIVE"]}],"booleanOperator":"AND"}}'` finds no one on the sample (`Sample` finds five). | The CLI and TUI send the option already, so case-insensitive matching starts working when the server reads it. Read `options` in the deserializer and declare it (an enum list) in the spec. | [qqq#1358](https://github.com/QRun-IO/qqq/issues/1358) |
| 28 | The sample app can be started with OAUTH2 against an owned provider (`OAuth2MetaDataProvider`, `OAUTH2_*` environment) but has no AUTH_0 configuration, so AUTH_0 sign-in (the server's `Auth0AuthenticationModule` verifying the JWT against the tenant) cannot be exercised end to end without a real Auth0 tenant. | `qqq-sample-project/src/main/java/.../metadata/`: only `OAuth2MetaDataProvider` reads environment variables. | The CLI signs in to AUTH_0 servers with the tenant from `values.baseUrl`, the client from `values.nativeClientId` (else `clientId`) and the `audience` (so the access token is a JWT); unit-tested in the client package. An `AUTH0_*` environment switch in the sample (pointing at the conformance dev IdP) would let SEC-029 run locally; SEC-030 needs tenant credentials. | [qqq#1359](https://github.com/QRun-IO/qqq/issues/1359) |
+55 -8
View File
@@ -8,6 +8,7 @@
"license": "Apache-2.0",
"workspaces": [
"packages/ui-api-client",
"packages/core",
"packages/conformance",
"packages/cli"
],
@@ -113,6 +114,10 @@
"resolved": "packages/cli",
"link": true
},
"node_modules/@qrun-io/qqq-core": {
"resolved": "packages/core",
"link": true
},
"node_modules/@qrun-io/qqq-ui-api-client": {
"resolved": "packages/ui-api-client",
"link": true
@@ -544,6 +549,13 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@types/jmespath": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@@ -1112,9 +1124,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"funding": [
{
"type": "github",
@@ -1551,6 +1563,15 @@
],
"license": "MIT"
},
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -2884,7 +2905,6 @@
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -2934,19 +2954,24 @@
},
"packages/cli": {
"name": "@qrun-io/qqq-cli",
"version": "0.1.0-rc.1",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.6.0-rc.1",
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
},
"bin": {
"qqq": "dist/main.js"
},
"engines": {
"node": ">=20"
}
},
"packages/conformance": {
"name": "@qrun-io/qqq-ui-api-conformance",
"version": "1.6.0-rc.1",
"version": "1.6.0",
"license": "Apache-2.0",
"dependencies": {
"ajv": "8.20.0"
@@ -2954,17 +2979,39 @@
"bin": {
"qqq-dev-idp": "dist/dev-idp.js",
"qqq-ui-api-conformance": "dist/main.js"
},
"engines": {
"node": ">=20"
}
},
"packages/core": {
"name": "@qrun-io/qqq-core",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
"devDependencies": {
"@types/jmespath": "0.15.2"
},
"engines": {
"node": ">=20"
}
},
"packages/ui-api-client": {
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.6.0-rc.1",
"version": "1.8.0",
"license": "Apache-2.0",
"dependencies": {
"openapi-fetch": "0.17.0"
},
"devDependencies": {
"openapi-typescript": "7.13.0"
},
"engines": {
"node": ">=20"
}
}
}
+13 -4
View File
@@ -9,15 +9,24 @@
},
"workspaces": [
"packages/ui-api-client",
"packages/core",
"packages/conformance",
"packages/cli"
],
"scripts": {
"spec:merge": "node scripts/merge-spec.mjs --out packages/ui-api-client/openapi/qqq-v1.json",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json"
"spec:merge-extensions": "node scripts/merge-extensions.mjs",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present && npm run test:scripts",
"test:scripts": "node --test scripts/update-packaging.test.mjs",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json",
"prepublishOnly": "npm run build",
"lint": "node scripts/lint.mjs",
"binaries": "node scripts/build-binaries.mjs",
"release": "node scripts/release.mjs",
"test:tui": "cd packages/tui && bun install --frozen-lockfile && bun test",
"typecheck:tui": "cd packages/tui && bun install --frozen-lockfile && npx tsc -p tsconfig.json"
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
+180
View File
@@ -0,0 +1,180 @@
# @qrun-io/qqq-cli
`qqq`, the command-line client for [QQQ](https://github.com/QRun-IO/qqq) applications. It talks to a QQQ
server only through the UI API (`/qqq/v1`), the same API the web UI uses, so every app, table, field,
process and extension comes from the server's metadata: one CLI for any QQQ application.
This npm package is **the CLI only**. The full-screen terminal UI (`qqq ui`) ships in the single-file
`qqq` binaries (curl installer, Homebrew, Scoop or the
[GitHub releases](https://github.com/QRun-IO/qqq-cli/releases)); run from this package, `qqq ui` says
where to get it.
## Install
The package is published to GitHub Packages, which needs a GitHub token with `read:packages` even for
public packages:
```sh
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm login --registry=https://npm.pkg.github.com # once, with that token
npm install -g @qrun-io/qqq-cli
qqq --version
```
Requires Node.js 20 or later. Without Node, use a binary:
`curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh`,
`brew install qrun-io/tap/qqq`, or `scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq`.
## Quick start
Against the QQQ sample server on `http://localhost:8077` (MOCK authentication):
```console
$ qqq login --server http://localhost:8077 --profile sample
Signed in to http://localhost:8077 (mock authentication) as profile "sample"; sign-in kept in the macos-keychain.
$ qqq table query person -w "id <= 3" --fields id,firstName,lastName
Id First Name Last Name
-- ---------- ---------
1 Avery Sample
2 Blair Sample
3 Casey Sample
(3 records; filter: id LESS_THAN_OR_EQUALS 3)
```
```sh
qqq whoami # the user, server, UI API version and extensions
qqq home # what you may open, with the command for each
qqq table get person 1 # one record, by section
qqq table query person -g "(firstName = Avery OR firstName = Blair) AND birthDate > now(-40y)"
qqq table create pet --set name=Pixel --set personId=2 --set speciesId=Dog
qqq table export person -w "id <= 5" --order -id --fields id,firstName,email -o people.xlsx
qqq table export person # no -o: person_2026-11-01_102316.csv in the download directory (local time)
qqq alerts get-inbox --columns id,severity,title # extension lists print as sub-tables
qqq process run greetInteractive -r 1,2 -v greetingPrefix=Hi
qqq api getTableMetaData --path tableName=person -Q 'fields.*.label'
qqq doctor # connection, sign-in and terminal checks
```
## Commands
`qqq <noun> <verb> [arguments] [options]`; `qqq <noun> --help` lists the verbs and `qqq <noun> <verb> --help`
the options.
| Noun | Verbs |
|---|---|
| `table` | `list`, `describe`, `query`, `count`, `stats`, `get`, `create`, `copy`, `update`, `delete`, `export`, `children`, `download`, `variants`, `columns`, `actions`, `bulk-insert`, `bulk-edit`, `bulk-delete`, `apis`, `api-doc`, `api-call`, `developer`, `widget-form` |
| `process` | `list`, `describe`, `run` (every step screen, async jobs with progress, Ctrl-C cancels on the server) |
| `home`, `find`, `apps`, `search`, `tail`, `whoami` | landing page, command palette, app homes, global search, live event stream, current user |
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports, saved reports, saved views, sharing, bulk-load mappings, audits, widgets as text, scripts, jobs, preferences, help |
| `api <operationId>` | any UI API operation by id |
| `extensions`, `esb`, `collab`, `health`, `alerts`, ... | one noun per extension, run when the server has it installed |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams block by block, rendered (tables as sub-tables, links as `label (qqq table get …)` or `label [n]` with the command under Links, never a URL), then its numbers (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `print`, `printer` | print records, table lists and text through the printing extension, follow print jobs; printers and print agents |
| `history` | everything you did with the CLI and the TUI, like a shell history (see below) |
| `login`, `logout`, `profile`, `config`, `cache`, `completion`, `doctor` | sign-in, saved server profiles (`profile list|add|edit|use|remove`), settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell), checks |
Global options, anywhere on the line: `--server <url>`, `-p, --profile <name>`, `-O, --output
json|table|text|csv|yaml`, `--json`, `-Q, --query <jmespath>`, `--fields <names>`, `-y, --yes`, `--utc`,
`--trace`. Output is a table on a terminal and JSON in a pipe; extension results print their scalars as
key/value rows and each list as a sub-table fitted to the terminal (`--columns` chooses, `--json` shows
everything). `table stats` computes from the matching records (up to `--max-records`, 10,000) when the server offers no column statistics (`--no-local` makes that an error); a pipe whose reader exits early (`| head -1`) ends the command quietly with status 0.
Flag convention: `-o, --out <file>` is the output file on every command that writes one; the sort is
`--order <field...>` (prefix `-` for descending; no short form). Until 1.1, `-o` still sorts on `table query`,
`saved-view save` and `saved-report preview/set`, with a deprecation note.
Exit codes: 0 ok, 1 error, 2 usage or bad filter, 3 auth, 4 server unreachable or rate limited,
5 not found, 6 validation, 70 server error, 130 interrupted.
## History
`qqq history` lists everything you did on the profile in use, with the CLI and in the terminal UI, like
`history` in a shell: number, time, client (`cli` or `ui`), kind and a one-line summary, newest last.
```
qqq history # the last 50
qqq history --since 2h --grep person
qqq history --kind query # kinds: cli, ui (the client) or command, open, query, record,
# process, export, print, server, auth
qqq history --commands # adds the command that does each entry again, in full
qqq history --absolute --limit 0 --all-profiles --format json
qqq history rerun 42 # runs entry 42 again (asks first when it changes data; --yes)
qqq history clear # empties the profile's history (asks; --yes)
```
Every CLI invocation is recorded as typed (with its exit code and duration); the TUI records its `:`
commands and what you open (apps, tables, records, with the CLI equivalent such as `qqq table get
person 3`), queries with filters (`qqq table query person -g "…"`), records created, edited and deleted
(which record and which fields, never values), process runs and their outcome, exports and prints, server
switches, and sign-in and sign-out. A TUI entry re-runs as its CLI equivalent; `:…` entries are TUI
commands and run on the `:` line of `qqq ui`.
The log is one JSONL file per profile, `~/.local/state/qqq/activity/<profile>.jsonl` (`QQQ_STATE_DIR`
moves it), one event per line; at 5 MB it is rotated once to `<profile>.jsonl.1`. Secrets never reach it:
values of `--token`, `--password`, `--pin` and other secret flags, secret `name=value` pairs, filter
criteria on secret or password fields, Authorization headers and bearer tokens are written as `***` (such
an entry cannot be re-run). `QQQ_HISTORY=off` stops recording.
## Sign-in
| Server authentication | What `qqq login` does |
|---|---|
| OAUTH2, AUTH_0 | OAuth2 device-authorization grant (RFC 8628); `--browser` uses the authorization code with PKCE on a loopback redirect. Tokens are refreshed before they expire; `qqq logout --provider-logout` revokes them at the provider. |
| TABLE_BASED | `--username <user>`; the password is asked for, or read from `QQQ_PASSWORD`. |
| MOCK, FULLY_ANONYMOUS | a server session from `POST /qqq/v1/manageSession`. |
Sign-ins are stored per profile in the OS keyring (macOS Keychain, Secret Service on Linux, DPAPI on
Windows), or in a 0600 file with `QQQ_KEYRING=file`. `QQQ_TOKEN` overrides the stored sign-in with a
bearer token, for scripts and CI.
## Configuration and environment
| Variable | Effect |
|---|---|
| `QQQ_SERVER`, `QQQ_PROFILE` | the server or profile when `--server` / `--profile` are not given |
| `QQQ_TOKEN` | a bearer token instead of the stored sign-in |
| `QQQ_PASSWORD` | the TABLE_BASED password, instead of a prompt |
| `QQQ_OUTPUT` | the default output format |
| `QQQ_KEYRING=file` | keep sign-ins in a 0600 file instead of the OS keyring |
| `QQQ_CONFIG_DIR`, `QQQ_STATE_DIR`, `QQQ_CACHE_DIR` | profiles and settings (`~/.config/qqq`), histories and the activity log (`~/.local/state/qqq`), metadata cache (`~/.cache/qqq`) |
| `QQQ_HISTORY=off` | do not record commands in the activity log (`qqq history`) |
| `QQQ_EXTENSION_CHECK=off` | skip the installed-extension check (for a mock server) |
## Use it as a library
The package also exports the program, so a tool can run CLI command lines in-process (the terminal UI's
`:` line does this):
```js
import { runCli } from '@qrun-io/qqq-cli/program'
let out = ''
const code = await runCli(['table', 'count', 'person', '-w', 'id <= 3', '-O', 'json'], {
stdout: (text) => { out += text },
stderr: (text) => process.stderr.write(text),
})
console.log(code, JSON.parse(out)) // 0 { count: 3 }
```
`runCli` uses the same profiles and sign-ins as `qqq`; prompts are off, so pass every value or `--yes`.
`createProgram()` returns the commander program itself. For building a client of your own, use
[`@qrun-io/qqq-core`](https://github.com/QRun-IO/qqq-cli/tree/main/packages/core#readme) and
[`@qrun-io/qqq-ui-api-client`](https://github.com/QRun-IO/qqq-cli/tree/main/packages/ui-api-client#readme)
directly.
## Compatibility and versions
- Servers: UI API 1.6.0 up to, not including, 2.0 (QQQ 4.1.0-RC.15 or later). `qqq --version` prints the
supported range and `qqq doctor` checks the server.
- The CLI follows semver. Its workspace dependencies (`@qrun-io/qqq-core`, `@qrun-io/qqq-ui-api-client`,
`@qrun-io/qqq-ui-api-conformance`) are pinned exactly and released together with it.
## More
- [Repository README](https://github.com/QRun-IO/qqq-cli#readme): install paths, the terminal UI, all packages
- [TRY-IT.md](https://github.com/QRun-IO/qqq-cli/blob/main/TRY-IT.md): every command against the sample server
- [CHANGELOG](https://github.com/QRun-IO/qqq-cli/blob/main/CHANGELOG.md) and
[issues](https://github.com/QRun-IO/qqq-cli/issues)
Apache-2.0
+44 -8
View File
@@ -1,30 +1,66 @@
{
"name": "@qrun-io/qqq-cli",
"version": "0.1.0-rc.1",
"version": "1.0.0",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"keywords": [
"qqq",
"qrun",
"ui-api",
"cli",
"command-line",
"low-code"
],
"license": "Apache-2.0",
"homepage": "https://github.com/QRun-IO/qqq-cli/tree/main/packages/cli#readme",
"bugs": {
"url": "https://github.com/QRun-IO/qqq-cli/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/cli"
},
"type": "module",
"bin": {
"qqq": "dist/main.js"
},
"exports": {
".": {
"types": "./dist/main.d.ts",
"default": "./dist/main.js"
},
"./program": {
"types": "./dist/program.d.ts",
"default": "./dist/program.js"
},
"./browser-login": {
"types": "./dist/browser-login.d.ts",
"default": "./dist/browser-login.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
"!dist/test",
"!dist/**/*.map",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/cli"
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
"test": "node ../../scripts/run-tests.mjs dist/test",
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.6.0-rc.1",
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Browser sign-in for `qqq login --browser`: the authorization-code grant with PKCE on a loopback
* redirect (RFC 8252). A one-shot HTTP listener on 127.0.0.1 receives the provider's redirect,
* the state is checked, and the code is exchanged at the provider's token endpoint with the
* verifier (UI-API-CLIENTS.md section 7). Nothing is saved unless every step succeeds.
*/
import { spawn } from 'node:child_process'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { authorizationUrl, createPkce, createState, discoverProvider, exchangeAuthorizationCode, readAuthorizationCallback, type NativeSignInConfig, type TokenSet } from '@qrun-io/qqq-ui-api-client'
export interface BrowserSignInOptions {
/** Loopback port (default: any free port). Some providers need the exact registered redirect URI. */
port?: number
/** Host in the redirect URI: 127.0.0.1 (default) or localhost, whichever the provider registered. */
host?: '127.0.0.1' | 'localhost'
/** Open the system browser (default true); the URL is always printed. */
open?: boolean
/** How long to wait for the redirect. */
timeoutMillis?: number
note: (text: string) => void
signal?: AbortSignal
}
/** Opens a URL with the platform's opener; failures are ignored (the URL is printed anyway). */
export function openInBrowser(url: string): void {
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
try {
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true })
child.on('error', () => undefined)
child.unref()
} catch {
// no opener: the printed URL is the way
}
}
const page = (title: string, text: string): string => `<!doctype html><meta charset="utf-8"><title>${title}</title><p style="font-family:sans-serif">${text}</p>`
/** Runs the browser sign-in and returns the provider's tokens. */
export async function browserSignIn(config: NativeSignInConfig, options: BrowserSignInOptions): Promise<TokenSet> {
const endpoints = await discoverProvider(config.issuer)
if (!endpoints.authorizationEndpoint) throw new Error('The provider publishes no authorization_endpoint; use the device flow (qqq login --device).')
const pkce = await createPkce()
const state = createState()
const host = options.host ?? '127.0.0.1'
let settle!: { resolve: (url: URL) => void; reject: (error: Error) => void }
const callback = new Promise<URL>((resolve, reject) => {
settle = { resolve, reject }
})
const server: Server = createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://${host}`)
if (url.pathname !== '/callback') {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
return
}
let ok = false
try {
readAuthorizationCallback(url, state)
ok = true
} catch {
ok = false
}
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
res.end(ok ? page('Signed in', 'Signed in. You can close this tab and return to the terminal.') : page('Sign-in failed', 'Sign-in did not complete. See the terminal for the reason.'))
settle.resolve(url)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(options.port ?? 0, '127.0.0.1', () => resolve())
})
const port = (server.address() as AddressInfo).port
const redirectUri = `http://${host}:${port}/callback`
const url = authorizationUrl(endpoints, config, { redirectUri, state, challenge: pkce.challenge })
const timer = setTimeout(() => settle.reject(new Error('Sign-in timed out waiting for the browser; run qqq login --browser again')), options.timeoutMillis ?? 300_000)
const onAbort = () => settle.reject(new Error('Sign-in cancelled'))
options.signal?.addEventListener('abort', onAbort, { once: true })
try {
options.note(`Open this URL to sign in${options.open === false ? '' : ' (opening your browser)'}:\n\n ${url}\n\nWaiting for the browser on ${redirectUri} ...`)
if (options.open !== false) openInBrowser(url)
const received = await callback
const { code } = readAuthorizationCallback(received, state)
return await exchangeAuthorizationCode(config, { code, verifier: pkce.verifier, redirectUri }, fetch, endpoints)
} finally {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onAbort)
server.close()
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { basename } from 'node:path'
import type { Command } from 'commander'
import { OPERATIONS, UNADVERTISED_HEALTH_NOTE, defaultListColumns, operation, parseColumnList, probeHealth, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readFileArg, readJson, writeDownload } from './shared.js'
export function registerApi(program: Command, ctx: () => Context): void {
program.command('api [operationId]')
.description('Call any UI API operation by its operationId (every operation of the document is reachable this way).')
.option('--path <name=value...>', 'path parameters')
.option('-q, --param <name=value...>', 'query parameters')
.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
.option('--bytes <file>', 'raw request body (application/octet-stream)')
.option('-o, --out <file>', 'write a binary response to a file (- for stdout)')
.option('-l, --list', 'list the operations instead of calling one')
.option('-e, --extension <name>', 'with --list: only this extension\'s operations (`core` for core ones)')
.action(async (operationId: string | undefined, options: OperationOptions & { list?: boolean; extension?: string }) => {
const c = ctx()
if (options.list || !operationId) {
const selected = OPERATIONS.filter((o) => !options.extension || (options.extension === 'core' ? !o.extension : o.extension === options.extension))
const rows = selected.map((o) => ({ operationId: o.id, extension: o.extension ?? '', method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
const extensions = new Set(selected.map((o) => o.extension).filter(Boolean)).size
c.print({ data: rows, columns: ['operationId', 'extension', 'method', 'path', 'body'], footer: `(${rows.length} operation${rows.length === 1 ? '' : 's'}; ${extensions} extensions; qqq api <operationId> --path k=v --param k=v --body-json '{...}'; whether an extension is installed: qqq whoami)` })
return
}
const op = operation(operationId)
if (!op) throw new UsageError(`No operation "${operationId}"; \`qqq api --list\` shows them.`)
await runOperation(c, op, options)
})
}
/** The options every generic operation command takes. */
export interface OperationOptions {
path?: string[]
param?: string[]
bodyJson?: string
form?: string[]
bytes?: string
out?: string
/** Table output: the columns of the result's lists (comma-separated). */
columns?: string
}
/**
* Calls one operation from command options and prints the result. An operation of an extension
* the server does not list is refused before the request (ExtensionNotInstalledError, exit 5).
*/
export async function runOperation(c: Context, op: OperationInfo, options: OperationOptions, pathValues: Record<string, string> = {}): Promise<void> {
if (op.id === 'getHealthSummary' && !(await c.extensions()).has('health')) {
// qqq-all and others serve /health without listing the extension: show that, marked as such
const probed = await probeHealth(c.server)
if (probed) {
c.note(`note: ${UNADVERTISED_HEALTH_NOTE}.`)
c.print({ data: probed, layout: { columns: { components: ['name', 'status', 'durationMs', 'details'] } } })
return
}
}
if (op.extension) (await c.extensions()).requireOperation(op.id)
const operationId = op.id
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileArg(v.slice(1), `--form ${k}`) as BlobPart], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: { ...pathValues, ...parsePairs(options.path, '--path') },
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? readFileArg(options.bytes, '--bytes') : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file> (or --out - for stdout)`)
const written = writeDownload(options.out, options.out, result.bytes)
if (written) c.note(`wrote ${written} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data, layout: { columns: listColumns(c, op, options) } })
}
/** The list columns for table output: --columns, else the global --fields, else the operation's defaults. */
export function listColumns(c: Context, op: OperationInfo, options: { columns?: string }): Record<string, string[]> | undefined {
const chosen = parseColumnList(options.columns) ?? c.fields
return chosen ? { '*': chosen } : defaultListColumns(op.id)
}
+166
View File
@@ -0,0 +1,166 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq assistant ask` and `qqq assistant config`: the first-class assistant commands, next to the
* generated operation subcommands (`get-config`, `post-conversations-turns`, ...).
*
* `ask` streams the answer as the model writes it (table and text output), then prints the numbers
* and the records and tables the answer refers to; `--json` prints the whole folded answer.
*/
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
import { io, type Context, UsageError } from '../context.js'
import { positiveInt, type Json } from './shared.js'
/** The answer's markdown as plain terminal text (bold and code markers dropped). */
export function plainAnswer(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
}
/** The qqq command that opens a link's target (`qqq table get t 4`, `qqq table query t --filter-json '…'`), or '' for other pages. */
export function linkCommand(href: string): string {
const target = parseAppHref(href)
if (!target?.tableName) return ''
const key = linkTargetKey(href)
const record = /^record:[^:]+:(.*)$/.exec(key)
if (record) return `qqq table get ${target.tableName} ${shellQuote(record[1]!)}`
if (key === `table:${target.tableName}:` || !target.filter) return `qqq table query ${target.tableName}`
return `qqq table query ${target.tableName} --filter-json ${shellQuote(JSON.stringify(target.filter))}`
}
function shellQuote(text: string): string {
return /^[A-Za-z0-9_.:@%+=,/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
}
/**
* Renders an answer's markdown for the terminal, block by block (so it can print while the model
* writes): entities decoded, tables as sub-tables, each link as `label (qqq table get …)` when that
* is short, else `label [n]` with the command listed under Links (numbered once per target).
*/
export class AnswerRenderer {
readonly footnotes: Array<{ n: number; label: string; open: string }> = []
private readonly noted = new Map<string, number>()
private printed = 0
constructor(private readonly width = 100) {}
private note(label: string, href: string): number {
const key = linkTargetKey(href)
let n = this.noted.get(key)
if (n === undefined) {
n = this.noted.size + 1
this.noted.set(key, n)
this.footnotes.push({ n, label, open: linkCommand(href) || href })
}
return n
}
/** Text for the blocks completed since the last call (all of them when `done`), '' when none. */
next(text: string, done: boolean): string {
const doc = parseMarkdown(text)
const complete = done ? doc.blocks.length : Math.max(0, doc.blocks.length - 1)
const out: string[] = []
for (; this.printed < complete; this.printed++) {
const block = doc.blocks[this.printed]!
const inTable = block.kind === 'table'
out.push(markdownText({ blocks: [block], links: doc.links }, this.width, (part: MdInline) => {
const open = linkCommand(part.href ?? '')
if (!inTable && open && !open.includes('--filter-json') && open.length <= 48) return `${part.text} (${open})`
return `${part.text} [${this.note(part.text, part.href ?? '')}]`
}))
}
return out.join('\n\n')
}
/** Numbers the answer's other links (tool results) the text did not show, once per target. */
extra(answer: { text: string; links: Array<{ label: string; href: string }> }): void {
for (const link of answerDocument(answer).extraLinks) this.note(link.label, link.href)
}
}
/** The whole answer rendered at once (tests, and output that is not streamed). */
export function renderAnswer(answer: { text: string; links: Array<{ label: string; href: string }> }, width = 100): { text: string; links: Array<{ n: number; label: string; open: string }> } {
const renderer = new AnswerRenderer(width)
const text = renderer.next(answer.text, true)
renderer.extra(answer)
return { text, links: renderer.footnotes }
}
export function registerAssistant(noun: Command, ctx: () => Context): void {
noun.command('config')
.description('Whether the assistant answers on this server, with which model, or why not (reason and what to configure).')
.action(async () => {
const c = ctx()
const view = await (await c.assistant()).config()
const { raw: _raw, ...data } = view
const rows: Json[] = [
{ setting: 'status', value: describeAssistantConfig(view) },
...(view.model ? [{ setting: 'model', value: view.model }] : []),
...(view.displayName ? [{ setting: 'name', value: view.displayName }] : []),
...(view.shortcut ? [{ setting: 'web shortcut', value: view.shortcut }] : []),
...(view.limits ? Object.entries(view.limits).map(([k, v]) => ({ setting: k, value: Array.isArray(v) ? v.join(', ') : String(v) })) : []),
...view.starterPrompts.map((p, i) => ({ setting: i === 0 ? 'try asking' : '', value: p })),
]
c.print({ data, rows, columns: ['setting', 'value'] })
})
noun.command('ask <question...>')
.description('Ask the assistant (read-only, with your permissions). Streams the answer, then prints its numbers and the records and tables it refers to.')
.option('-t, --table <table>', 'ask about a table: sent as the page context, as the web UI does')
.option('-r, --record <id>', 'with --table: ask about this record')
.option('-c, --conversation <id>', 'continue a conversation (the id is printed after each answer)')
.addHelpText('after', '\nExamples:\n qqq assistant ask "how many customers are there"\n qqq assistant ask --table order --record 42 "what changed on this order?"\n qqq assistant ask -c 3 "and how many orders?"\n qqq assistant ask --json "top 5 customers by orders"')
.action(async (words: string[], options: { table?: string; record?: string; conversation?: string }) => {
const c = ctx()
const question = words.join(' ').trim()
if (!question) throw new UsageError('give a question')
if (options.record && !options.table) throw new UsageError('--record needs --table')
const conversationId = options.conversation ? positiveInt(options.conversation, '--conversation') : undefined
const assistant = await c.assistant()
const human = c.format === 'table' || c.format === 'text'
const renderer = new AnswerRenderer(Math.max(40, io.width() ?? 100))
let printedAny = false
const emit = (text: string) => {
if (!text) return
io.out(`${printedAny ? '\n\n' : ''}${text}`)
printedAny = true
}
const answer = await assistant.ask(question, {
conversationId,
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
onEvent: (event, partial) => {
if (!human) return
// whole blocks as the model completes them, rendered (a table prints once it is complete)
if (event.type === 'text.delta') emit(renderer.next(partial.text, false))
else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) c.note(`… ${event.summary}`)
},
})
if (answer.status === 'ERROR') {
if (printedAny) io.out('\n')
throw new UiApiError(502, { code: answer.error?.code ?? 'INTERNAL', message: answer.error?.message ?? 'The assistant could not answer.' })
}
if (!human) {
c.print({ data: answer })
return
}
emit(renderer.next(answer.text, true))
io.out('\n')
for (const notice of answer.notices) c.note(`${notice.level}: ${notice.text}`)
if (answer.numbers.length) {
io.out('\n')
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
}
renderer.extra(answer)
if (renderer.footnotes.length) {
io.out('\nLinks\n')
c.print({ data: renderer.footnotes, rows: renderer.footnotes.map((f) => ({ n: `[${f.n}]`, label: f.label, open: f.open })), columns: ['n', 'label', 'open'] })
}
const seconds = answer.elapsedMs === undefined ? '' : `${(answer.elapsedMs / 1000).toFixed(1)} s, `
c.note(`(${seconds}conversation ${answer.conversationId}; continue with: qqq assistant ask -c ${answer.conversationId} "...")`)
})
}
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { auditSource, getAudits } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import { countFooter, instantText, type Json } from './shared.js'
export function registerAudit(program: Command, ctx: () => Context): void {
program.command('audit').description('Audit history: list.').command('list <table> <primaryKey>')
.description("A record's audit history: who changed what, newest first (the audit process, or the audit table when you may not run it).")
.option('--asc', 'oldest first')
.action(async (table: string, primaryKey: string, options: { asc?: boolean }) => {
const c = ctx()
const meta = await c.tableMeta<Json>(table)
const source = auditSource(meta, await c.metaData() as unknown as Json)
if (!source) throw new UsageError(`No audits you may read for ${meta.label} on this server (no audit process or table).`)
const fields = (meta.fields ?? {}) as Record<string, Json>
const { entries, total } = await getAudits(c.server, authenticatedFetch(c.target), source, table, primaryKey, { ascending: options.asc })
const rows = entries.flatMap((e) => (e.changes.length ? e.changes : [undefined]).map((change, i) => ({
when: i ? '' : instantText(e.timestamp, c.dateTimeDisplay),
user: i ? '' : e.user,
action: i ? '' : e.action.toLowerCase(),
change: change ? (change.message ?? `${String(fields[change.fieldName]?.label ?? change.fieldName)}: ${String(change.oldValue ?? '')} -> ${String(change.newValue ?? '')}`) : e.message,
})))
c.print({ data: entries, rows, columns: ['when', 'user', 'action', 'change'], footer: countFooter(entries.length, 'audit', [total !== null && total > entries.length && `of ${total}`, `read through the ${source.kind === 'process' ? `${source.processName} process` : `${source.tableName} table`}`]) })
})
}
+163
View File
@@ -0,0 +1,163 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { UiApiError, createSession, decodeJwtClaims, deviceSignIn, endSessionUrl, getAuthenticationMetaData, revokeToken } from '@qrun-io/qqq-ui-api-client'
import { browserSignIn, openInBrowser } from '../browser-login.js'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { ask, askHidden } from '../prompt.js'
import { PasswordSignInError, signInWithPasswordCookie } from '@qrun-io/qqq-core'
import { CLIENT_CHANNEL, currentToken, getLogin, nativeConfig, removeLogin, saveLogin, secretStoreKind } from '../session.js'
import type { Json } from './shared.js'
export function registerAuth(program: Command, ctx: () => Context): void {
program.command('login')
.description('Sign in. OAUTH2 and AUTH_0 servers use the device-authorization grant (or --browser: authorization code with PKCE on a loopback redirect); MOCK and FULLY_ANONYMOUS servers get a server session; TABLE_BASED servers ask for a username and password.')
.option('--device', 'force the device-authorization grant')
.option('--browser', 'sign in in the browser: authorization code + PKCE, redirect to http://127.0.0.1:{port}/callback')
.option('--no-open', 'with --browser, print the URL instead of opening the browser')
.option('--port <n>', 'with --browser, the loopback port (default: any free port)')
.option('--redirect-host <host>', 'with --browser, 127.0.0.1 (default) or localhost, as the provider registered it')
.option('-u, --username <name>', 'TABLE_BASED servers: the username (the password is asked for, or read from QQQ_PASSWORD)')
.action(async (options: { device?: boolean; browser?: boolean; open: boolean; port?: string; redirectHost?: string; username?: string }) => {
const c = ctx()
const t = c.target
const origin = t.server
if (t.movedFrom) {
// QQQ_PROFILE (or --profile) names an existing profile and --server another server: that moves the profile
const where = t.source === 'env' ? `QQQ_PROFILE=${t.name}` : `--profile ${t.name}`
c.note(`warning: profile "${t.name}" (${where}) points at ${t.movedFrom}; signing in to ${origin} would point it there instead. To keep it and add a profile for ${origin}, pass --profile <new-name>.`)
let move: boolean
try {
move = await c.confirm(`Point profile "${t.name}" at ${origin}?`)
} catch (error) {
if (error instanceof UsageError) throw new UsageError(`Profile "${t.name}" points at ${t.movedFrom}, not ${origin}; nothing was changed. Pass --yes to point it at ${origin}, or --profile <new-name> to add a profile.`)
throw error
}
if (!move) {
c.note(`Nothing changed; profile "${t.name}" still points at ${t.movedFrom}.`)
return
}
}
const auth = await getAuthenticationMetaData(origin)
const type = String(auth.type ?? '').toUpperCase()
if (options.device && options.browser) throw new UsageError('--device and --browser exclude each other.')
if (options.redirectHost && options.redirectHost !== '127.0.0.1' && options.redirectHost !== 'localhost') throw new UsageError('--redirect-host is 127.0.0.1 or localhost.')
if (!options.device && !options.browser && (type === 'MOCK' || type === 'FULLY_ANONYMOUS')) {
const session = await createSession(origin, {}, CLIENT_CHANNEL)
const user = (session.values.user ?? {}) as Record<string, unknown>
await saveLogin(t, { kind: 'session', authType: type, sessionUUID: session.sessionUUID, user, createdAt: new Date().toISOString() }, { moveProfile: true })
c.note(`Signed in to ${origin} (${type.toLowerCase()} authentication${user.name || user.email ? ` as ${user.name ?? user.email}` : ''}) as profile "${t.name}"; sign-in kept in the ${secretStoreKind()}.`)
return
}
if (options.browser && type !== 'OAUTH2' && type !== 'AUTH_0') throw new UsageError(`--browser needs an OAUTH2 or AUTH_0 server; this one uses ${type}.`)
if (!options.device && type === 'TABLE_BASED') {
// username and password over HTTP Basic once; the server answers with a sessionId cookie, kept as the sign-in (never the password)
let username = options.username
if (!username) {
if (!process.stdin.isTTY) throw new UsageError('This server signs in with a username and password: --username <name> (password from QQQ_PASSWORD without a terminal).')
username = (await ask(`Username for ${origin}: `)).trim()
}
const password = process.env.QQQ_PASSWORD ?? (process.stdin.isTTY ? await askHidden('Password: ') : undefined)
if (!password) throw new UsageError('No password: run on a terminal to be asked, or set QQQ_PASSWORD.')
let sessionId: string
try {
({ sessionId } = await signInWithPasswordCookie(origin, username, password, CLIENT_CHANNEL))
} catch (error) {
// one message for a wrong password or an unknown user; nothing is stored
if (error instanceof PasswordSignInError) throw new UiApiError(401, { code: 'UNAUTHENTICATED', message: error.message })
throw error
}
await saveLogin(t, { kind: 'session', authType: type, sessionUUID: sessionId, cookieName: 'sessionId', user: { username }, createdAt: new Date().toISOString() }, { moveProfile: true })
c.note(`Signed in to ${origin} as ${username} (table-based authentication) as profile "${t.name}"; the session is kept in the ${secretStoreKind()}, the password is not.`)
return
}
if (!options.device && type !== 'OAUTH2' && type !== 'AUTH_0') throw new UsageError(`Unknown authentication type "${auth.type}"; this CLI knows OAUTH2, AUTH_0, TABLE_BASED, MOCK and FULLY_ANONYMOUS.`)
const config = await nativeConfig(origin)
const tokens = await (options.browser
? browserSignIn(config, { port: options.port ? Number(options.port) : undefined, host: options.redirectHost as '127.0.0.1' | 'localhost' | undefined, open: options.open, note: (text) => c.note(text) })
: deviceSignIn(config, (prompt) => {
c.note(`To sign in to ${origin}, open:\n\n ${prompt.verificationUriComplete ?? prompt.verificationUri}\n`)
c.note(`and confirm the code ${prompt.userCode} (it expires in ${Math.round(prompt.expiresIn / 60)} minutes).`)
c.note('Waiting for approval...')
})).catch((error: unknown) => {
// a denied, forged or failed sign-in is an authentication failure (exit 3); nothing was saved
throw new UiApiError(401, { code: 'UNAUTHENTICATED', message: error instanceof Error ? error.message : String(error) })
})
const claims = decodeJwtClaims(tokens.accessToken)
// the server must accept the token before the sign-in counts (audience, issuer, bearer tokens enabled)
const check = await fetch(new URL('/qqq/v1/metaData', origin), { headers: { Authorization: `Bearer ${tokens.accessToken}`, 'X-QQQ-Client': CLIENT_CHANNEL } })
if (check.status === 401 || check.status === 403) throw new UiApiError(check.status, await check.json().catch(() => ({ message: `the server refused the new token (HTTP ${check.status})` })))
await saveLogin(t, { kind: 'oauth', config, tokens }, { moveProfile: true })
c.note(`Signed in to ${origin} as ${claims.email ?? claims.preferred_username ?? claims.sub} (profile "${t.name}", ${options.browser ? 'browser' : 'device'} sign-in kept in the ${secretStoreKind()}).`)
})
program.command('logout')
.description("Forget this server's sign-in (and end a server session). --provider-logout also revokes the refresh token at the provider and ends the provider session.")
.option('--provider-logout', 'OAuth2 sign-ins: revoke the refresh token (RFC 7009) and open the provider end-session URL')
.option('--no-open', 'with --provider-logout, print the end-session URL instead of opening the browser')
.action(async (options: { providerLogout?: boolean; open: boolean }) => {
const c = ctx()
const t = c.target
const origin = t.server
// an unreadable stored sign-in is still removed (that is how it gets replaced)
const login = await getLogin(t).catch(() => undefined)
if (options.providerLogout && login && login.kind !== 'session') {
try {
const revoked = login.tokens.refreshToken ? await revokeToken(login.config, login.tokens.refreshToken) : false
c.note(revoked ? 'Revoked the refresh token at the provider.' : 'The provider has no revocation endpoint (or there was no refresh token).')
} catch (error) {
c.note(`Revoking at the provider failed: ${error instanceof Error ? error.message : String(error)}`)
}
const url = await endSessionUrl(login.config, { idToken: login.tokens.idToken })
if (url) {
c.note(`End the provider session${options.open ? ' (opening your browser)' : ''}:\n\n ${url}\n`)
if (options.open) openInBrowser(url)
} else {
c.note('The provider publishes no end_session_endpoint; sign out there yourself to end its session.')
}
} else if (options.providerLogout && login?.kind === 'session') {
c.note('This sign-in is a server session, not an OAuth2 sign-in; there is no provider session to end.')
}
if (login?.kind === 'session') {
try {
await c.call('logout', {})
} catch {
// the session may already be gone
}
}
c.note((await removeLogin(t)) ? `Signed out of ${origin} (profile "${t.name}").` : `Not signed in to ${origin}.`)
})
program.command('whoami')
.description('Show the signed-in user, the server version and its extensions.')
.action(async () => {
const c = ctx()
const t = c.target
const origin = t.server
const version = await c.unwrap(c.client.GET('/qqq/v1/version')) as Json
const token = await currentToken(t)
const claims = token && token.includes('.') ? decodeJwtClaims(token) : {}
const metaData = await c.metaData() as unknown as Json
const login = await getLogin(t)
const sessionUser = login?.kind === 'session' ? login.user ?? {} : {}
const result = {
profile: `${t.name} (${t.source})`,
server: origin,
auth: login?.kind === 'session' ? `${login.authType.toLowerCase()} session` : login ? 'oauth2 bearer' : process.env.QQQ_TOKEN ? 'QQQ_TOKEN' : 'none',
user: claims.email ?? claims.preferred_username ?? claims.sub ?? sessionUser.email ?? sessionUser.username ?? sessionUser.name ?? '',
name: claims.name ?? sessionUser.displayName ?? sessionUser.name ?? '',
tokenExpires: login && login.kind !== 'session' ? new Date(login.tokens.expiresAt).toISOString() : '',
apiVersion: version.apiVersion,
features: ((version.features ?? []) as string[]).join(', '),
extensions: (await c.extensions({ refresh: true })).describe(),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
}
c.print({ data: result, rows: Object.entries(result).map(([key, value]) => ({ key, value })), columns: ['key', 'value'] })
})
}
+841
View File
@@ -0,0 +1,841 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Bulk load and bulk changes: `qqq table bulk-insert`, `bulk-edit`, `bulk-delete`, `actions`, and
* `qqq bulk-profile` (saved bulk load profiles). The steps are the kernel's (`runBulkProcess`);
* this file answers each screen from flags, a mapping document or terminal prompts.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { basename, extname } from 'node:path'
import type { Command } from 'commander'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import {
BACK, BulkDeclinedError, BulkMappingError, FilterLabelError, resolveFilterLabels, FormInputError, FormSession, ProcessCancelledError, ProcessError, SavedBulkLoadProfileError, blocksToText, buildFilter, bulkProcesses,
checkInputRecords, checkMapping, columnLetter, columnName, columnPreview, deleteSavedBulkLoadProfile, diffMappings, displayDateTime, filterDateTimesToUtc, findColumn, findSavedBulkLoadProfile,
formatTable, formatValue, isProfileOwner, listSavedBulkLoadProfiles, mappingDocument, mappingFromProfile, mappingRows, parseMappingDocument, profileFromMapping, readFileDescription, resolvePossibleValue,
runBulkProcess, safeFileName, savedBulkLoadProfilesAvailable, screenToText, searchPossibleValues, setHasHeaderRow, storeSavedBulkLoadProfile, stripTags, structureFields, uploadTemplates, variantParam,
type BulkLoadTableStructure, type BulkMapping, type BulkOutcome, type FieldMeta, type FileDescription, type MappingContext, type ProcessMeta, type QueryFilter, type ReviewContext, type ReviewDecision,
type SavedBulkLoadProfile, type ScreenBlock, type ValueMappingContext,
} from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { ask, promptForm } from '../prompt.js'
import { authenticatedFetch } from '../session.js'
import { printOutcome } from './process.js'
import { currentUserId } from './saved-view.js'
import { countFooter, parsePairs, positiveInt, readFileArg, readJson, writeDownload, requireTable, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
interface SelectOptions { caseSensitive?: boolean; records?: string; where?: string[]; group?: string[]; or?: boolean; filterJson?: string; allRecords?: boolean }
interface ReviewOptions { validate?: boolean; skipValidation?: boolean; dryRun?: boolean; quiet?: boolean; recordLimit: string; variant?: string }
interface FileOptions { file?: string; mapping?: string; column?: string[]; default?: string[]; layout?: string; header: boolean; saved?: string; saveMapping?: string; saveProfile?: string; interactive?: boolean; key?: string; template?: string; out?: string }
/** The records a bulk edit or delete runs on: -r ids, or a filter (--where/--group/--filter-json), or --all-records. */
function selection(options: SelectOptions, meta: Json): { ids: string[] } | { filter: QueryFilter } {
const given = [options.records, options.where?.length || options.group?.length ? 'filter' : undefined, options.filterJson, options.allRecords ? 'all' : undefined].filter(Boolean)
if (given.length === 0) throw new UsageError('Choose the records: -r <ids>, --where/--group criteria, --filter-json, or --all-records for every record of the table.')
if (given.length > 1) throw new UsageError('-r, --where/--group, --filter-json and --all-records exclude each other.')
if (options.records) return { ids: options.records.split(',').map((x) => x.trim()).filter(Boolean) }
if (options.filterJson) return { filter: readJson(options.filterJson, '--filter-json') as QueryFilter }
if (options.allRecords) return { filter: { criteria: [], booleanOperator: 'AND' } as unknown as QueryFilter }
const fields = (meta.fields ?? {}) as Record<string, FieldMeta>
return { filter: filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, knownFields: Object.keys(fields), caseSensitive: options.caseSensitive }), fields) }
}
/** selection, with possible-value labels in --where resolved to ids. */
async function resolvedSelection(c: Context, options: SelectOptions, meta: Json): Promise<{ ids: string[] } | { filter: QueryFilter }> {
const chosen = selection(options, meta)
if (!('filter' in chosen) || options.filterJson) return chosen
try {
const { filter, notes } = await resolveFilterLabels(c.server, authenticatedFetch(c.target), String(meta.name), (meta.fields ?? {}) as Record<string, FieldMeta>, chosen.filter)
for (const n of notes) c.note(`filter: ${n}`)
return { filter }
} catch (error) {
if (error instanceof FilterLabelError) throw new UsageError(`--where: ${error.message}`)
throw error
}
}
function addSelectOptions(cmd: Command): Command {
return cmd
.option('-r, --records <ids>', 'comma-separated primary keys (the checked records)')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or): the whole query result')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--case-sensitive', 'text matches in --where match case (by default they ignore it)')
.option('--filter-json <json|file|->', 'a QueryFilter selecting the records')
.option('--all-records', 'every record of the table')
}
function addReviewOptions(cmd: Command): Command {
return cmd
.option('--validate', 'run full validation before loading (the default when there is a choice)')
.option('--skip-validation', 'load without full validation')
.option('--dry-run', 'stop at the review (after validation): show what would happen, change nothing')
.option('--record-limit <n>', 'most preview records the review shows', '20')
.option('--quiet', 'no intermediate screens on stderr')
.option(...VARIANT_OPTION)
}
function addFileOptions(cmd: Command): Command {
return cmd
.option('--mapping <file>', 'a mapping document (YAML or JSON): fields to columns, defaults, value mappings; or a saved profile\'s v1 JSON')
.option('-c, --column <field=column...>', 'map a field to a column (header name or letter); adds to or replaces the mapping')
.option('-d, --default <field=value...>', 'a value for every row (possible values by label or id)')
.option('--layout <layout>', 'FLAT, TALL or WIDE (tables with child tables)')
.option('--no-header', 'the file has no header row (columns are letters)')
.option('--saved <id|name>', 'start from a saved bulk load profile')
.option('--save-mapping <file>', 'write the mapping used as a document (YAML, or JSON for .json) for --mapping next time')
.option('--save-profile <name>', 'store the mapping used as a saved bulk load profile (yours) after the load')
.option('-i, --interactive', 'review and edit the mapping and the value mappings on the terminal')
}
export function registerBulk(program: Command, ctx: () => Context): void {
const table = program.commands.find((x) => x.name() === 'table')!
addFileOptions(addReviewOptions(table.command('bulk-insert <table>')
.description('Bulk insert from a CSV or Excel file: upload, map columns to fields (the server\'s suggestion, a --mapping document, flags or -i), map values, review (validation), load. Asks before loading unless --yes.')
.option('-f, --file <path>', 'the CSV or .xlsx file to load')
.option('--template [layout]', 'save the template file the upload instructions offer (flat, tall, wide; default all) and stop')
.option('--out <path>', 'with --template: where to save (a file, or - for stdout)')))
.action(async (name: string, options: FileOptions & ReviewOptions) => {
const c = ctx()
const meta = await c.tableMeta<Json>(name)
requireTable(meta as unknown as Json, 'insert')
const processName = bulkProcesses(meta, await c.metaData() as unknown as Json).insert
if (!processName) throw new UsageError(`${meta.label} offers no bulk insert to you.`)
if (options.template !== undefined) return await saveTemplates(c, processName, name, options)
if (!options.file) throw new UsageError('--file <path> names the file to load (or --template to get a template).')
await runFileLoad(c, meta, processName, options, undefined)
})
addFileOptions(addReviewOptions(addSelectOptions(table.command('bulk-edit <table>')
.description('Bulk edit: the chosen records (-r, --where, --filter-json) get the --set values (only those fields change; --clear empties one); with --file, each file row updates the record found by --key fields. Review, then asks unless --yes.'))
.option('-s, --set <name=value...>', 'a field and its new value (possible values by label or id)')
.option('--clear <field...>', 'fields to empty')
.option('-f, --file <path>', 'edit with a file: rows matched to records by --key')
.option('-k, --key <fields>', 'with --file: the key field(s) that find each record (name or label; a|b for a compound key)')))
.action(async (name: string, options: FileOptions & ReviewOptions & SelectOptions & { set?: string[]; clear?: string[] }) => {
const c = ctx()
const meta = await c.tableMeta<Json>(name)
requireTable(meta as unknown as Json, 'edit')
const processes = bulkProcesses(meta, await c.metaData() as unknown as Json)
if (options.file) {
if (!processes.editWithFile) throw new UsageError(`${meta.label} offers no bulk edit with a file to you.`)
if (options.records || options.where || options.filterJson || options.allRecords || options.set || options.clear) throw new UsageError('With --file the file rows choose the records and the values; -r/--where/--set/--clear do not apply.')
return await runFileLoad(c, meta, processes.editWithFile, options, options.key)
}
if (!processes.edit) throw new UsageError(`${meta.label} offers no bulk edit to you.`)
if (!options.set?.length && !options.clear?.length && !options.interactive) throw new UsageError('Give the new values with --set field=value (or --clear field), or -i to choose them on the terminal.')
await runSelected(c, meta, processes.edit, options, 'edit')
})
addReviewOptions(addSelectOptions(table.command('bulk-delete <table>')
.description('Bulk delete the chosen records (-r ids, the whole --where result, --filter-json): review with the count and a preview, optional validation, then asks unless --yes.')))
.action(async (name: string, options: ReviewOptions & SelectOptions) => {
const c = ctx()
const meta = await c.tableMeta<Json>(name)
requireTable(meta as unknown as Json, 'delete')
const processName = bulkProcesses(meta, await c.metaData() as unknown as Json).delete
if (!processName) throw new UsageError(`${meta.label} offers no bulk delete to you.`)
await runSelected(c, meta, processName, options, 'delete')
})
table.command('actions <table>')
.description("What you can do to a table's records beyond query and edit, with the command for each: bulk insert, bulk edit (with and without a file), bulk delete, and the table's processes.")
.option('--all', 'include hidden processes')
.action(async (name: string, options: { all?: boolean }) => {
const c = ctx()
const meta = await c.tableMeta<Json>(name)
const metaData = await c.metaData() as unknown as Json
const bulk = bulkProcesses(meta, metaData)
const bulkNames = new Set(Object.values(bulk))
const rows: Json[] = []
if (bulk.insert) rows.push({ action: 'Bulk insert', kind: 'bulk load', process: bulk.insert, command: `qqq table bulk-insert ${name} --file <file>` })
if (bulk.editWithFile) rows.push({ action: 'Bulk edit with a file', kind: 'bulk load', process: bulk.editWithFile, command: `qqq table bulk-edit ${name} --file <file> --key <field>` })
if (bulk.edit) rows.push({ action: 'Bulk edit', kind: 'bulk', process: bulk.edit, command: `qqq table bulk-edit ${name} -r <ids> --set <field=value>` })
if (bulk.delete) rows.push({ action: 'Bulk delete', kind: 'bulk', process: bulk.delete, command: `qqq table bulk-delete ${name} -r <ids>` })
const processes = Object.values((metaData.processes ?? {}) as Record<string, Json>)
.filter((p) => p.tableName === name && !bulkNames.has(String(p.name)) && (options.all || !p.isHidden))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
for (const p of processes) rows.push({ action: p.label, kind: p.hasPermission === false ? 'process (no permission)' : 'process', process: p.name, command: `qqq process run ${String(p.name)} -r <ids>` })
c.print({ data: rows, columns: ['action', 'kind', 'process', 'command'], footer: countFooter(rows.length, 'action') })
})
registerBulkProfile(program, ctx)
}
/** Saves the upload instructions' template file(s) (data: links in upload.html) and cancels the run. */
async function saveTemplates(c: Context, processName: string, table: string, options: FileOptions): Promise<void> {
const init = (await c.call('initProcess', { path: { processName }, form: { values: JSON.stringify({ tableName: table }), stepTimeoutMillis: '60000' } })).data as Json
if (init.type !== 'COMPLETE') throw new ProcessError(String(init.userFacingError ?? init.error ?? 'the bulk insert did not start'))
const values = (init.values ?? {}) as Json
const html = Object.entries(values).find(([k]) => k.endsWith('.html'))?.[1]
await c.call('cancelProcess', { path: { processName, processUUID: String(init.processUUID) } }).catch(() => undefined)
const templates = uploadTemplates(html)
if (!templates.length) throw new UsageError('The upload instructions offer no template file.')
const wanted = typeof options.template === 'string' ? options.template.toLowerCase() : undefined
const chosen = wanted ? templates.filter((t) => t.name.toLowerCase().includes(wanted)) : templates
if (!chosen.length) throw new UsageError(`No template matches "${options.template}" (offered: ${templates.map((t) => t.name).join(', ')}).`)
if (options.out && chosen.length > 1) throw new UsageError(`--out takes one template; choose one of: ${templates.map((t) => t.name).join(', ')}`)
for (const t of chosen) {
const written = writeDownload(options.out, safeFileName(t.name), t.bytes)
if (written) c.note(`saved ${written} (${t.bytes.length} bytes)`)
}
}
/** The screens as text (records with the table renderer, widgets with the block renderer). */
function screenText(screen: ScreenBlock[]): string {
const width = process.stdout.columns ?? 100
return screenToText(screen, { table: (rows, columns, headers) => formatTable(rows, columns, headers), widget: (blocks) => blocksToText(blocks, { width }) })
}
/** Ctrl-C cancels the run on the server; a second leaves at once. */
function interruptible(c: Context): { signal: AbortSignal; done: () => void } {
const abort = new AbortController()
const onInterrupt = () => {
if (abort.signal.aborted) process.exit(130)
c.note('\ncancelling (Ctrl-C again to leave at once)...')
abort.abort()
}
process.on('SIGINT', onInterrupt)
return { signal: abort.signal, done: () => process.off('SIGINT', onInterrupt) }
}
/**
* The review screen's answer: validate first unless --skip-validation (asked on a terminal), then
* submit with --yes or after a y; --dry-run stops after the validation; without a terminal and
* without --yes the run is cancelled and the command exits 2.
*/
function reviewer(c: Context, options: ReviewOptions, state: { needYes?: boolean; dryRun?: boolean }, verb: string) {
const canPrompt = Boolean(process.stdin.isTTY)
return async (ctx: ReviewContext): Promise<ReviewDecision | typeof BACK> => {
if (options.validate && options.skipValidation) throw new UsageError('--validate and --skip-validation exclude each other.')
if (!ctx.validated && ctx.supportsFullValidation) {
if (options.dryRun || options.validate) return 'validate'
if (options.skipValidation) {
if (c.options.yes) return 'skip'
if (!canPrompt) {
state.needYes = true
return 'cancel'
}
const answer = await ask(`${verb} now without full validation? [y/N${ctx.canGoBack ? '/b' : ''}] `)
if (ctx.canGoBack && /^b/i.test(answer)) return BACK
return /^y/i.test(answer) ? 'skip' : 'cancel'
}
if (c.options.yes || !canPrompt) return 'validate'
const answer = await ask(`Validate fully before you ${verb.toLowerCase()} (v), ${verb.toLowerCase()} now without validation (r)${ctx.canGoBack ? ', go back (b)' : ''}, or cancel (c)? [V/r${ctx.canGoBack ? '/b' : ''}/c] `)
if (ctx.canGoBack && /^b/i.test(answer)) return BACK
if (/^c/i.test(answer)) return 'cancel'
return /^r/i.test(answer) ? 'skip' : 'validate'
}
if (options.dryRun) {
state.dryRun = true
return 'cancel'
}
if (c.options.yes) return 'submit'
if (!canPrompt) {
state.needYes = true
return 'cancel'
}
const answer = await ask(`${verb}? [y/N${ctx.canGoBack ? '/b' : ''}] `)
if (ctx.canGoBack && /^b/i.test(answer)) return BACK
return /^y/i.test(answer) ? 'submit' : 'cancel'
}
}
/** Runs the kernel driver with the shared screen printing, Ctrl-C handling and endings. */
async function drive(c: Context, meta: Json, processName: string, options: ReviewOptions, verb: string, setup: (state: { needYes?: boolean; dryRun?: boolean }, review: ReturnType<typeof reviewer>) => Omit<Parameters<typeof runBulkProcess>[0], 'baseUrl' | 'fetch' | 'meta' | 'review'>): Promise<BulkOutcome | undefined> {
const procMeta = await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as unknown as ProcessMeta
const tableVariant = options.variant !== undefined || meta.backendDetails ? variantParam(await chooseVariant(c, meta, options.variant)) : undefined
const fetchImpl = authenticatedFetch(c.target)
const state: { needYes?: boolean; dryRun?: boolean } = {}
const review = reviewer(c, options, state, verb)
const { signal, done } = interruptible(c)
let outcome: BulkOutcome
try {
outcome = await runBulkProcess({
baseUrl: c.server,
fetch: fetchImpl,
meta: procMeta,
review,
signal,
tableVariant,
stepTimeoutMillis: 0,
screen: { recordLimit: positiveInt(options.recordLimit, '--record-limit'), previewLimit: positiveInt(options.recordLimit, '--record-limit'), format: (field, value, shown) => (field.type === 'DATE_TIME' ? displayDateTime(value, shown, c.dateTimeDisplay) : formatValue(field, value)) },
onProgress: (e) => {
if (options.quiet) return
if (e.phase === 'init') c.note(`${procMeta.label ?? processName}: starting`)
else if (e.phase === 'running') c.note(` running${e.current !== undefined ? ` ${e.current}/${e.total ?? '?'}` : ''}${e.message ? ` ${e.message}` : ''}`)
else if (e.phase === 'back') c.note(' going back')
else if (e.phase === 'cancel') c.note(' asking the server to cancel the run')
},
present: ({ step, screen, isLast }) => {
if (isLast || options.quiet) return
const text = screenText(screen.filter((b) => b.kind !== 'form'))
if (text) c.note(`\n== ${step.label ?? step.name} ==\n${text}`)
},
...setup(state, review),
})
} catch (error) {
if (error instanceof BulkDeclinedError) {
if (state.needYes) throw new UsageError(`Confirmation needed: pass --yes to ${verb.toLowerCase()} without a terminal (the run was cancelled; nothing changed).`)
c.note(state.dryRun ? `Dry run: nothing was changed (${procMeta.label ?? processName} cancelled at the review).` : `Not submitted; nothing was changed.`)
return undefined
}
if (error instanceof ProcessCancelledError) {
c.note(`${procMeta.label ?? processName}: ${error.message}`)
process.exitCode = 130
return undefined
}
if (error instanceof BulkMappingError) throw new UsageError(error.message)
throw error
} finally {
done()
}
printOutcome(c, outcome, screenText, [])
return outcome
}
/** bulk-edit (no file) and bulk-delete: records chosen by ids or a filter. */
async function runSelected(c: Context, meta: Json, processName: string, options: ReviewOptions & SelectOptions & { set?: string[]; clear?: string[]; interactive?: boolean }, kind: 'edit' | 'delete'): Promise<void> {
const records = await resolvedSelection(c, options, meta)
const procMeta = await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as unknown as ProcessMeta
try {
await checkInputRecords(procMeta, records, async (filter) => Number(((await c.call('countTable', { path: { tableName: String(meta.name) }, json: { filter } })).data as Json).count ?? 0))
} catch (error) {
if (error instanceof ProcessError) throw new UsageError(error.message)
throw error
}
const given = parsePairs(options.set, '--set')
const fetchImpl = authenticatedFetch(c.target)
await drive(c, meta, processName, options, kind === 'edit' ? 'Update the records' : 'Delete the records', () => ({
records,
editForm: async ({ step, fields, values }) => {
const names = new Set(fields.map((f) => f.name))
const resolveName = (n: string): string => {
if (names.has(n)) return n
const byLabel = fields.find((f) => String(f.label ?? '').toLowerCase() === n.toLowerCase())
if (byLabel) return byLabel.name
throw new UsageError(`${step.label ?? step.name}: ${n} is not a field bulk edit can change (${fields.map((f) => f.name).join(', ')})`)
}
const enabled: string[] = []
const session = new FormSession({ baseUrl: c.server, fetch: fetchImpl, scope: { process: processName }, fields, initialValues: values })
for (const [n, v] of Object.entries(given)) {
const fieldName = resolveName(n)
try {
await session.set(fieldName, v === '' ? null : v)
} catch (error) {
if (error instanceof FormInputError) throw new UsageError(error.message)
throw error
}
enabled.push(fieldName)
}
for (const n of options.clear ?? []) {
const fieldName = resolveName(n)
await session.set(fieldName, null)
if (!enabled.includes(fieldName)) enabled.push(fieldName)
}
if (options.interactive) {
c.note(`Fields: ${fields.map((f) => `${f.label} (${f.name})`).join(', ')}`)
const answer = await ask(`Fields to change (comma-separated names or labels${enabled.length ? `; Enter keeps ${enabled.join(', ')}` : ''}): `)
for (const n of answer.split(',').map((x) => x.trim()).filter(Boolean)) {
const fieldName = resolveName(n)
if (!enabled.includes(fieldName)) enabled.push(fieldName)
}
if (!enabled.length) throw new UsageError('No fields chosen; nothing to change.')
c.note('An empty answer clears the field in every record.')
await promptForm(session, { only: enabled })
}
const problems = session.validate(enabled.filter((n) => session.values[n] !== null && session.values[n] !== undefined))
if (Object.keys(problems).length) throw new UsageError(Object.entries(problems).map(([f, m]) => `${session.fields[f]?.label ?? f} ${m}`).join('; '))
c.note(`Changing ${enabled.map((n) => `${session.fields[n]?.label ?? n} = ${session.values[n] === null || session.values[n] === undefined ? '(cleared)' : String(session.state(n).displayValue ?? session.values[n])}`).join(', ')}`)
return { values: session.values, enabled }
},
}))
}
/** bulk-insert and bulk-edit --file: upload, mapping, value mapping, review, result. */
async function runFileLoad(c: Context, meta: Json, processName: string, options: FileOptions & ReviewOptions, key: string | undefined): Promise<void> {
const path = options.file!
const bytes = readFileArg(path, '--file')
const fetchImpl = authenticatedFetch(c.target)
const layout = options.layout?.toUpperCase()
if (layout && !['FLAT', 'TALL', 'WIDE'].includes(layout)) throw new UsageError(`--layout ${options.layout}: one of FLAT, TALL, WIDE`)
const doc = options.mapping ? readMappingFile(options.mapping) : undefined
let saved: SavedBulkLoadProfile | undefined
const isBulkEdit = key !== undefined
if (options.saved) {
if (!savedBulkLoadProfilesAvailable(await c.metaData() as unknown as Json)) throw new UsageError('This server offers no saved bulk load profiles.')
try {
saved = findSavedBulkLoadProfile(await listSavedBulkLoadProfiles(c.server, fetchImpl, String(meta.name), isBulkEdit), options.saved)
} catch (error) {
if (error instanceof SavedBulkLoadProfileError) throw new UsageError(error.message)
throw error
}
}
const outcome = await drive(c, meta, processName, options, isBulkEdit ? 'Update the records' : 'Load the file', () => ({
file: { name: basename(path), bytes, type: contentTypeOf(path) },
values: { tableName: String(meta.name) },
savedProfile: saved,
chooseMapping: async (ctx) => {
let mapping = doc !== undefined ? parseMappingDocument(doc, ctx.structure, ctx.file) : structuredClone(ctx.suggestion)
if (ctx.previous && doc === undefined) mapping = structuredClone(ctx.previous)
if (options.header === false && mapping.hasHeaderRow) for (const w of setHasHeaderRow(mapping, false, ctx.file)) c.note(`warning: ${w}`)
if (layout) mapping.layout = layout as BulkMapping['layout']
applyFlags(mapping, ctx, options)
if (isBulkEdit) mapping.keyFields = await chooseKey(ctx, key!, mapping)
if (options.interactive) {
const edited = await editMapping(c, ctx, mapping)
if (edited === BACK) return BACK
mapping = edited
} else if (!options.quiet) {
showMapping(c, ctx, mapping)
}
const { errors, warnings } = checkMapping(mapping, ctx.structure, ctx.file)
for (const w of warnings) c.note(`warning: ${w}`)
if (errors.length) throw new UsageError(`The mapping cannot be used:\n ${errors.join('\n ')}\n(map with --column field=column or --default field=value, a --mapping document, or -i)`)
if (options.saveMapping) {
const docOut = mappingDocument(mapping, ctx.file)
writeFileSync(options.saveMapping, extname(options.saveMapping).toLowerCase() === '.json' ? `${JSON.stringify(docOut, null, 2)}\n` : stringifyYaml(docOut))
c.note(`mapping saved to ${options.saveMapping}`)
}
return mapping
},
mapValues: async (ctx) => mapValues(c, ctx, Boolean(options.interactive)),
}))
if (outcome?.mapping && options.saveProfile && outcome.structure && outcome.file) {
try {
const stored = await storeSavedBulkLoadProfile(c.server, fetchImpl, { table: String(meta.name), label: options.saveProfile, isBulkEdit, profile: profileFromMapping(outcome.mapping) })
c.note(`saved bulk load profile "${stored.label}" (${stored.id})`)
} catch (error) {
if (error instanceof SavedBulkLoadProfileError) throw new UsageError(`The load finished, but the profile was not saved: ${error.message}`)
throw error
}
}
}
function contentTypeOf(path: string): string | undefined {
const ext = extname(path).toLowerCase()
return ext === '.csv' ? 'text/csv' : ext === '.xlsx' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : undefined
}
/** A mapping document file: YAML or JSON. */
function readMappingFile(path: string): unknown {
const text = new TextDecoder().decode(readFileArg(path, '--mapping'))
try {
return extname(path).toLowerCase() === '.json' ? JSON.parse(text) : parseYaml(text)
} catch (error) {
throw new UsageError(`--mapping ${path}: not valid ${extname(path).toLowerCase() === '.json' ? 'JSON' : 'YAML'} (${error instanceof Error ? error.message : String(error)})`)
}
}
/** A field by name, qualified name, label or `Child: Label`. */
function fieldByText(structure: BulkLoadTableStructure, text: string): { name: string; label: string; field: FieldMeta; tableName: string; isMany: boolean } {
const all = structureFields(structure)
const { base, wide } = (() => {
const m = /^(.*),(\d+)$/.exec(text.trim())
return m ? { base: m[1]!, wide: m[2] } : { base: text.trim(), wide: undefined }
})()
const found = all.find((f) => f.name === base) ?? all.find((f) => f.label.toLowerCase() === base.toLowerCase()) ?? all.find((f) => String(f.field.label ?? '').toLowerCase() === base.toLowerCase() && f.isMain)
if (!found) throw new UsageError(`${text}: no such field (${all.map((f) => f.name).join(', ')})`)
return { ...found, name: wide !== undefined ? `${found.name},${wide}` : found.name }
}
/** --column and --default on top of the mapping. */
function applyFlags(mapping: BulkMapping, ctx: MappingContext, options: FileOptions): void {
for (const [text, column] of Object.entries(parsePairs(options.column, '--column'))) {
const f = fieldByText(ctx.structure, text)
const index = findColumn(ctx.file, column, mapping.hasHeaderRow)
if (index === undefined) throw new UsageError(`--column ${text}=${column}: no such column (${columnsList(ctx.file, mapping.hasHeaderRow)})`)
const existing = mapping.fields.find((m) => m.name === f.name)
const entry = { name: f.name, column: index, ...(mapping.hasHeaderRow ? { header: ctx.file.headers[index] } : {}) }
if (existing) {
delete existing.defaultValue
Object.assign(existing, entry)
} else mapping.fields.push(entry)
}
for (const [text, value] of Object.entries(parsePairs(options.default, '--default'))) {
const f = fieldByText(ctx.structure, text)
const existing = mapping.fields.find((m) => m.name === f.name)
if (existing) {
delete existing.column
delete existing.header
delete existing.doValueMapping
delete existing.valueMappings
existing.defaultValue = value
} else mapping.fields.push({ name: f.name, defaultValue: value })
}
}
function columnsList(file: FileDescription, hasHeaderRow: boolean): string {
return file.letters.map((l, i) => (hasHeaderRow ? `${l} ${file.headers[i]}` : l)).join(', ')
}
/** Bulk edit with a file: --key names the key field(s), checked against the server's choices. */
async function chooseKey(ctx: MappingContext, key: string, mapping: BulkMapping): Promise<string> {
const names = key.split(/[|,]/).map((k) => fieldByText(ctx.structure, k).name)
const wanted = names.join('|')
const options = await ctx.keyFieldOptions()
if (options.length && !options.some((o) => o.id === wanted)) throw new UsageError(`--key ${key}: not a key of this table (keys: ${options.map((o) => `${o.id} (${o.label})`).join(', ')})`)
for (const name of names) {
if (mapping.fields.some((f) => f.name === name && f.column !== undefined)) continue
const label = structureFields(ctx.structure).find((f) => f.name === name)?.label ?? name
const column = findColumn(ctx.file, label, mapping.hasHeaderRow) ?? findColumn(ctx.file, name, mapping.hasHeaderRow)
if (column === undefined) continue
const existing = mapping.fields.find((f) => f.name === name)
if (existing) Object.assign(existing, { column, ...(mapping.hasHeaderRow ? { header: ctx.file.headers[column] } : {}) })
else mapping.fields.push({ name, column, ...(mapping.hasHeaderRow ? { header: ctx.file.headers[column] } : {}) })
}
return wanted
}
function showMapping(c: Context, ctx: MappingContext, mapping: BulkMapping): void {
const rows = mappingRows(mapping, ctx.structure, ctx.file)
const head = [`File ${ctx.file.name ?? ''}: ${ctx.file.headers.length} columns${mapping.hasHeaderRow ? '' : ' (no header row)'}; layout ${mapping.layout}${mapping.isBulkEdit && mapping.keyFields ? `; key ${mapping.keyFields}` : ''}${ctx.savedProfile ? `; profile ${ctx.savedProfile.label}` : ''}`]
c.note(`\n== ${ctx.step.label ?? ctx.step.name} ==\n${head.join('\n')}\n${formatTable(rows, ['field', 'source', 'preview', 'options'], ['Field', 'Source', 'Preview', 'Options'])}`)
}
/**
* -i on the mapping screen: the file's columns and the mapping, then questions: header row,
* layout, each mapped field's source, fields to add (grouped by table), fields whose values to
* map; repeated until accepted. `b` goes back to the upload.
*/
async function editMapping(c: Context, ctx: MappingContext, start: BulkMapping): Promise<BulkMapping | typeof BACK> {
const mapping = structuredClone(start)
const columns = ctx.file.headers.map((h, i) => ({ col: ctx.file.letters[i] ?? columnLetter(i), header: h, preview: columnPreview(ctx.file, i, true).slice(0, 3).join(', ') }))
c.note(`\n== ${ctx.step.label ?? ctx.step.name}: ${ctx.file.name ?? 'the file'} ==\n${formatTable(columns, ['col', 'header', 'preview'], ['Col', 'First row', 'Preview'])}`)
const helps = (ctx.step.formFields ?? []).map((f) => (f as FieldMeta & { helpContents?: Array<{ contentAsText?: string; content?: string }> })).filter((f) => f.helpContents?.length)
for (const f of helps) c.note(` ${f.label}: ${stripTags(f.helpContents!.map((h) => h.contentAsText ?? h.content ?? '').join(' ')).trim()}`)
for (;;) {
const header = await ask(`Does the first row hold headers? [${mapping.hasHeaderRow ? 'Y/n' : 'y/N'}] `)
if (header) for (const w of setHasHeaderRow(mapping, /^y/i.test(header), ctx.file)) c.note(`warning: ${w}`)
if (ctx.structure.associations?.length) {
const answer = (await ask(`Layout: FLAT (one row per record), TALL (rows grouped into child records), WIDE (child records in extra columns) [${mapping.layout}]: `)).toUpperCase()
if (['FLAT', 'TALL', 'WIDE'].includes(answer)) mapping.layout = answer as BulkMapping['layout']
}
const label = (n: string) => mappingRows({ ...mapping, fields: mapping.fields.filter((f) => f.name === n) }, ctx.structure, ctx.file)[0]?.field ?? n
c.note('For each field: a column (header or letter), =value for a default value, - to drop it, Enter to keep.')
for (const f of [...mapping.fields]) {
const current = f.column !== undefined ? columnName(ctx.file, f.column, mapping.hasHeaderRow) : f.defaultValue !== undefined ? `=${String(f.defaultValue)}` : 'not mapped'
for (;;) {
const answer = await ask(` ${label(f.name)} (${current}): `)
if (!answer) break
if (answer === '-') {
mapping.fields = mapping.fields.filter((m) => m !== f)
break
}
if (answer.startsWith('=')) {
delete f.column
delete f.header
delete f.doValueMapping
f.defaultValue = answer.slice(1)
break
}
const index = findColumn(ctx.file, answer, mapping.hasHeaderRow)
if (index === undefined) {
c.note(` no column ${answer} (${columnsList(ctx.file, mapping.hasHeaderRow)})`)
continue
}
delete f.defaultValue
f.column = index
if (mapping.hasHeaderRow) f.header = ctx.file.headers[index]
break
}
}
const unused = structureFields(ctx.structure).filter((s) => !mapping.fields.some((m) => m.name.replace(/,\d+$/, '') === s.name) || (s.isMany && mapping.layout === 'WIDE'))
if (unused.length) {
const groups = new Map<string, string[]>()
for (const u of unused) {
const table = u.isMain ? String(ctx.structure.label) : u.label.split(':')[0]!
groups.set(table, [...(groups.get(table) ?? []), `${u.isMain ? u.label : u.label.split(': ')[1]} (${u.name})`])
}
c.note(`Fields you can add:\n${[...groups].map(([t, fs]) => ` ${t}: ${fs.join(', ')}`).join('\n')}`)
const add = await ask('Add fields (comma-separated; Enter for none): ')
for (const text of add.split(',').map((x) => x.trim()).filter(Boolean)) {
let f: ReturnType<typeof fieldByText>
try {
f = fieldByText(ctx.structure, text)
} catch (error) {
c.note(` ${error instanceof Error ? error.message : String(error)}`)
continue
}
let name = f.name
if (f.isMany && mapping.layout === 'WIDE' && !/,\d+$/.test(name)) name = `${name},${mapping.fields.filter((m) => m.name.startsWith(`${f.name},`)).length}`
for (;;) {
const answer = await ask(` ${f.label}: column (header or letter) or =default value: `)
if (!answer) break
if (answer.startsWith('=')) {
mapping.fields.push({ name, defaultValue: answer.slice(1) })
break
}
const index = findColumn(ctx.file, answer, mapping.hasHeaderRow)
if (index === undefined) {
c.note(` no column ${answer}`)
continue
}
mapping.fields.push({ name, column: index, ...(mapping.hasHeaderRow ? { header: ctx.file.headers[index] } : {}) })
break
}
}
}
const columnFields = mapping.fields.filter((f) => f.column !== undefined)
if (columnFields.length) {
const current = columnFields.filter((f) => f.doValueMapping).map((f) => f.name)
const answer = await ask(`Map file values to table values for which fields? (comma-separated${current.length ? `; now: ${current.join(', ')}; - for none` : ''}; Enter keeps): `)
if (answer === '-') for (const f of columnFields) delete f.doValueMapping
else if (answer) {
const names = answer.split(',').map((x) => fieldByText(ctx.structure, x.trim()).name)
for (const f of columnFields) {
if (names.includes(f.name.replace(/,\d+$/, '')) || names.includes(f.name)) f.doValueMapping = true
else delete f.doValueMapping
}
}
const clear = mapping.isBulkEdit ? await ask('Clear a field when its file value is empty, for which fields? (comma-separated, Enter for none): ') : ''
if (clear) {
const names = clear.split(',').map((x) => fieldByText(ctx.structure, x.trim()).name)
for (const f of columnFields) if (names.includes(f.name)) f.clearIfEmpty = true
}
}
if (mapping.isBulkEdit) {
const options = await ctx.keyFieldOptions()
if (options.length) {
const answer = await ask(`Key fields that find each record (${options.map((o) => `${o.id} = ${o.label}`).join('; ')}) [${mapping.keyFields ?? ''}]: `)
if (answer) {
const found = options.find((o) => o.id === answer || o.label.toLowerCase() === answer.toLowerCase())
if (found) mapping.keyFields = found.id
else c.note(` ${answer} is not one of the keys`)
}
}
}
showMapping(c, ctx, mapping)
const { errors, warnings } = checkMapping(mapping, ctx.structure, ctx.file)
for (const w of warnings) c.note(`warning: ${w}`)
if (errors.length) c.note(`Not ready:\n ${errors.join('\n ')}`)
const answer = await ask(`Use this mapping? [${errors.length ? 'y/N' : 'Y/n'}${ctx.canGoBack ? '/b' : ''}] (n edits again) `)
if (ctx.canGoBack && /^b/i.test(answer)) return BACK
if (errors.length ? /^y/i.test(answer) : !/^n/i.test(answer)) return mapping
}
}
/**
* The value mapping screen: file values to table values for one field. Known mappings (profile,
* mapping document) are used; on a terminal each value is asked for (choices listed for
* possible-value fields, Enter keeps the known one); without one, a required field with unmapped
* values stops with the values to map.
*/
async function mapValues(c: Context, ctx: ValueMappingContext, interactive: boolean): Promise<Record<string, unknown> | typeof BACK> {
const mapped: Record<string, unknown> = { ...ctx.mapped }
const fetchImpl = authenticatedFetch(c.target)
const label = String(ctx.field.label ?? ctx.field.name)
const unmapped = () => ctx.fileValues.filter((v) => mapped[v] === undefined || mapped[v] === null || mapped[v] === '')
if (!interactive) {
const missing = unmapped()
if (missing.length && ctx.field.isRequired) throw new UsageError(`${label} (${ctx.fieldName}) needs a table value for each file value; unmapped: ${missing.map((v) => JSON.stringify(v)).join(', ')}. Map them in a --mapping document (fields: { ${ctx.fieldName}: { column: …, values: { "${missing[0]}": <value> } } }) or run with -i.`)
if (missing.length) c.note(`${label}: ${missing.length} file value${missing.length === 1 ? '' : 's'} not mapped (sent as in the file): ${missing.slice(0, 10).join(', ')}`)
return mapped
}
c.note(`\n== Value mapping: ${label} (${ctx.index + 1} of ${ctx.count}) ==`)
if (ctx.field.possibleValueSourceName) {
const choices = await searchPossibleValues(c.server, fetchImpl, { table: ctx.tableName }, ctx.field.name, { searchTerm: '', limit: 15 })
if (choices.length) c.note(`Choices: ${choices.map((o) => `${o.label} (${String(o.id)})`).join(', ')}${choices.length === 15 ? ', … (type part of a label to search)' : ''}`)
} else if (ctx.field.type === 'BOOLEAN') c.note('Answer yes or no.')
c.note(`For each file value: the ${label} value${ctx.field.possibleValueSourceName ? ' (label or id)' : ''}, Enter keeps what is shown, - clears${ctx.canGoBack ? ', b goes back' : ''}.`)
for (const fileValue of ctx.fileValues) {
for (;;) {
const known = mapped[fileValue]
const shown = known === undefined || known === null || known === '' ? '' : ` (${ctx.labels[String(known)] ?? String(known)})`
const answer = await ask(` ${fileValue}${shown}${ctx.field.isRequired && !shown ? ' *' : ''}: `)
if (answer === 'b' && ctx.canGoBack) return BACK
if (!answer) {
if (ctx.field.isRequired && !shown) {
c.note(' required')
continue
}
break
}
if (answer === '-') {
delete mapped[fileValue]
break
}
if (ctx.field.possibleValueSourceName) {
const { option, candidates } = await resolvePossibleValue(c.server, fetchImpl, { table: ctx.tableName }, ctx.field.name, answer, { fieldType: ctx.field.type })
if (!option) {
c.note(` ${candidates.length ? `several match: ${candidates.slice(0, 8).map((o) => `${o.label} (${String(o.id)})`).join(', ')}` : 'no match'}`)
continue
}
mapped[fileValue] = option.id
ctx.labels[String(option.id)] = option.label
} else mapped[fileValue] = answer
break
}
}
return mapped
}
/** The header row and first rows of a local CSV (for saving a profile from a mapping document). */
function localFileDescription(path: string): FileDescription {
if (extname(path).toLowerCase() !== '.csv') throw new UsageError('--file: a CSV whose header names the columns (for an Excel file, save the profile from bulk-insert --save-profile)')
const rows = parseCsv(readFileSync(path, 'utf8')).slice(0, 6)
const width = Math.max(0, ...rows.map((r) => r.length))
const headers = Array.from({ length: width }, (_, i) => rows[0]?.[i] ?? '')
return readFileDescription({ headerValues: headers, headerLetters: headers.map((_, i) => columnLetter(i)), bodyValuesPreview: headers.map((_, i) => rows.slice(1).map((r) => r[i] ?? '')), fileBaseName: basename(path) })
}
/** A small RFC 4180 reader (quotes, doubled quotes, CRLF). */
export function parseCsv(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]!
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"'
i++
} else if (ch === '"') quoted = false
else cell += ch
} else if (ch === '"') quoted = true
else if (ch === ',') {
row.push(cell)
cell = ''
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++
row.push(cell)
rows.push(row)
row = []
cell = ''
} else cell += ch
}
if (cell || row.length) {
row.push(cell)
rows.push(row)
}
return rows
}
/** `qqq bulk-profile`: saved bulk load profiles. */
function registerBulkProfile(program: Command, ctx: () => Context): void {
const bp = program.command('bulk-profile').description('Saved bulk load profiles (column mappings kept by the server): list, show, save, rename, delete. Use one with `table bulk-insert --saved <name>`.')
const setup = async (c: Context, table: string) => {
const meta = await c.tableMeta<Json>(table)
if (!savedBulkLoadProfilesAvailable(await c.metaData() as unknown as Json)) throw new UsageError('This server offers no saved bulk load profiles (the querySavedBulkLoadProfile, storeSavedBulkLoadProfile and deleteSavedBulkLoadProfile processes).')
return { meta, fetchImpl: authenticatedFetch(c.target), me: await currentUserId(c) }
}
const guard = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f()
} catch (error) {
if (error instanceof SavedBulkLoadProfileError || error instanceof BulkMappingError) throw new UsageError(error.message)
throw error
}
}
const mustOwn = (p: SavedBulkLoadProfile, me: string | undefined, verb: string) => {
if (!isProfileOwner(p, me)) throw new UsageError(`"${p.label}" is shared with you by ${p.userId ?? 'another user'}; only its owner may ${verb} it.`)
}
bp.command('list <table>')
.description('Your profiles for the table, then those shared with you.')
.option('--edit', 'bulk-edit-with-file profiles instead of bulk insert ones')
.action(async (table: string, options: { edit?: boolean }) => {
const c = ctx()
const { fetchImpl, me } = await setup(c, table)
const profiles = await guard(() => listSavedBulkLoadProfiles(c.server, fetchImpl, table, Boolean(options.edit)))
const rows = profiles.map((p) => ({ id: p.id, label: p.label, owner: isProfileOwner(p, me) ? 'you' : `shared by ${p.userId ?? '?'}`, layout: p.profile.layout ?? '', fields: (p.profile.fieldList ?? []).map((f) => f.fieldName).join(', ') }))
const ordered = [...rows.filter((r) => r.owner === 'you'), ...rows.filter((r) => r.owner !== 'you')]
c.print({ data: profiles, rows: ordered, columns: ['id', 'label', 'owner', 'layout', 'fields'], footer: countFooter(profiles.length, 'profile') })
})
bp.command('show <table> <profile>')
.description('One profile: its fields and their columns, defaults and value mappings (--output yaml gives a --mapping document).')
.option('--edit', 'a bulk-edit-with-file profile')
.action(async (table: string, name: string, options: { edit?: boolean }) => {
const c = ctx()
const { fetchImpl, me } = await setup(c, table)
const p = await guard(async () => findSavedBulkLoadProfile(await listSavedBulkLoadProfiles(c.server, fetchImpl, table, Boolean(options.edit)), name))
const rows = (p.profile.fieldList ?? []).map((f) => ({ field: f.fieldName, source: f.headerName ? `column ${f.headerName}` : f.columnIndex !== undefined && f.columnIndex !== null ? `column ${columnLetter(f.columnIndex)}` : `default ${String(f.defaultValue ?? '')}`, options: [f.doValueMapping ? `map values${f.valueMappings ? ` (${Object.entries(f.valueMappings).map(([k, v]) => `${k}=${String(v)}`).join(', ')})` : ''}` : '', f.clearIfEmpty ? 'clear if empty' : ''].filter(Boolean).join('; ') }))
c.print({ data: p, rows, columns: ['field', 'source', 'options'], footer: `${p.label} (${p.id}), ${isProfileOwner(p, me) ? 'yours' : `shared by ${p.userId ?? '?'}`}; layout ${p.profile.layout}; header row ${p.profile.hasHeaderRow === false ? 'no' : 'yes'}${p.profile.keyFields ? `; key ${p.profile.keyFields}` : ''}` })
})
bp.command('save <table>')
.description('Save a mapping as a profile: --mapping (a document, read against --file\'s header row, or a v1 profile JSON). --replace updates your profile of that name after showing what changes.')
.requiredOption('-n, --name <name>', 'the profile name')
.requiredOption('--mapping <file>', 'the mapping document (YAML/JSON) or a v1 profile JSON')
.option('-f, --file <csv>', 'a CSV with the header row the document names')
.option('--edit', 'a bulk-edit-with-file profile')
.option('--replace', 'update your profile with this name')
.action(async (table: string, options: { name: string; mapping: string; file?: string; edit?: boolean; replace?: boolean }) => {
const c = ctx()
const { meta, fetchImpl, me } = await setup(c, table)
const processName = bulkProcesses(meta, await c.metaData() as unknown as Json)[options.edit ? 'editWithFile' : 'insert']
if (!processName) throw new UsageError(`${meta.label} offers no ${options.edit ? 'bulk edit with a file' : 'bulk insert'} to you.`)
const structure = await tableStructureFor(c, processName, table)
const doc = readMappingFile(options.mapping) as Json
const file = options.file ? localFileDescription(options.file) : readFileDescription({ headerValues: [] })
if (!Array.isArray(doc.fieldList) && !options.file) throw new UsageError('--file <csv> is needed to read a mapping document\'s column names (a v1 profile JSON needs none).')
const mapping = await guard(async () => parseMappingDocument(doc, { ...structure, isBulkEdit: Boolean(options.edit) }, file))
const profiles = await guard(() => listSavedBulkLoadProfiles(c.server, fetchImpl, table, Boolean(options.edit)))
const same = profiles.filter((p) => p.label.toLowerCase() === options.name.toLowerCase())
const mine = same.find((p) => isProfileOwner(p, me))
if (mine && !options.replace) throw new UsageError(`You already have a profile "${mine.label}" (${mine.id}); pass --replace to update it.`)
if (options.replace && !mine) {
if (same.length) mustOwn(same[0]!, me, 'save over')
throw new UsageError(`No profile of yours named "${options.name}" to replace.`)
}
if (mine) {
const diff = diffMappings(mappingFromProfile(mine.profile, structure), mapping, structure, file)
c.note(diff.length ? `Changes to "${mine.label}":\n ${diff.join('\n ')}` : `No changes to "${mine.label}".`)
if (!await c.confirm(`Update the profile "${mine.label}"?`)) {
c.note('Not saved.')
return
}
}
const stored = await guard(() => storeSavedBulkLoadProfile(c.server, fetchImpl, { table, label: options.name, isBulkEdit: Boolean(options.edit), profile: profileFromMapping(mapping), id: mine?.id }))
c.print({ data: stored, rows: [{ id: stored.id, label: stored.label, saved: mine ? 'updated' : 'created' }], columns: ['id', 'label', 'saved'] })
})
bp.command('rename <table> <profile> <newName>')
.description('Rename one of your profiles.')
.option('--edit', 'a bulk-edit-with-file profile')
.action(async (table: string, name: string, newName: string, options: { edit?: boolean }) => {
const c = ctx()
const { fetchImpl, me } = await setup(c, table)
const profiles = await guard(() => listSavedBulkLoadProfiles(c.server, fetchImpl, table, Boolean(options.edit)))
const p = await guard(async () => findSavedBulkLoadProfile(profiles, name))
mustOwn(p, me, 'rename')
if (profiles.some((o) => o.id !== p.id && isProfileOwner(o, me) && o.label.toLowerCase() === newName.toLowerCase())) throw new UsageError(`You already have a profile named "${newName}".`)
const stored = await guard(() => storeSavedBulkLoadProfile(c.server, fetchImpl, { table, label: newName, isBulkEdit: p.isBulkEdit, profile: p.profile, id: p.id }))
c.print({ data: stored, rows: [{ id: stored.id, label: stored.label, was: p.label }], columns: ['id', 'label', 'was'] })
})
bp.command('delete <table> <profile>')
.description('Delete one of your profiles (asks unless --yes).')
.option('--edit', 'a bulk-edit-with-file profile')
.action(async (table: string, name: string, options: { edit?: boolean }) => {
const c = ctx()
const { fetchImpl, me } = await setup(c, table)
const p = await guard(async () => findSavedBulkLoadProfile(await listSavedBulkLoadProfiles(c.server, fetchImpl, table, Boolean(options.edit)), name))
mustOwn(p, me, 'delete')
if (!await c.confirm(`Delete the bulk load profile "${p.label}" (${p.id})?`)) {
c.note('Not deleted.')
return
}
await guard(() => deleteSavedBulkLoadProfile(c.server, fetchImpl, p.id))
c.note(`Deleted "${p.label}".`)
})
}
/** The table structure a bulk process offers (init, read `tableStructure`, cancel). */
async function tableStructureFor(c: Context, processName: string, table: string): Promise<BulkLoadTableStructure> {
const init = (await c.call('initProcess', { path: { processName }, form: { values: JSON.stringify({ tableName: table }), stepTimeoutMillis: '60000' } })).data as Json
if (init.type !== 'COMPLETE') throw new ProcessError(String(init.userFacingError ?? init.error ?? 'the bulk process did not start'))
await c.call('cancelProcess', { path: { processName, processUUID: String(init.processUUID) } }).catch(() => undefined)
const structure = ((init.values ?? {}) as Json).tableStructure as BulkLoadTableStructure | undefined
if (!structure) throw new ProcessError('the bulk process sent no table structure')
return structure
}
+261
View File
@@ -0,0 +1,261 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* First-class commands of `qqq collab` (the collaboration extension): presence, comments,
* favorites, tags, recents, follow, my work and people. The kernel's Collaboration module does the
* calls; every command requires the extension first (exit 5 with the installed list).
*/
import type { Command } from 'commander'
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
import type { DisplayOptions } from '@qrun-io/qqq-core'
const TARGET_TYPES = ['TABLE', 'APP', 'RECORD', 'PROCESS', 'REPORT', 'SAVED_VIEW']
async function collab(c: Context) {
;(await c.extensions()).require(COLLABORATION)
return c.collaboration()
}
function hhmmss(date: Date): string {
return date.toTimeString().slice(0, 8)
}
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
export function presenceLine(state: PresenceState): string {
if (!state.available) return 'presence: the collaboration extension is not installed'
if (state.error) return `presence: ${state.error}`
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
if (!state.count) return `nobody on ${where}`
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
return `${state.count} on ${where}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
return items.map((f) => ({ id: f.id ?? '', type: f.targetType ?? '', name: f.targetName ?? '', record: f.recordId ?? '', label: f.label ?? '', folder: f.folder ?? '', available: f.available ?? '', href: f.href ?? '' }))
}
function commentRows(threads: CommentEntry[], display?: DisplayOptions): Json[] {
const rows: Json[] = []
const add = (comment: CommentEntry, depth: number) => {
rows.push({
id: comment.id ?? '',
author: comment.authorDisplayName ?? comment.authorUserId ?? '',
at: instantText(comment.createdAt ?? '', display),
state: comment.deleted ? 'deleted' : comment.resolvedAt ? 'resolved' : '',
body: `${depth ? ' > ' : ''}${String(comment.body ?? '').replace(/\s+/g, ' ').slice(0, 100)}`,
})
for (const reply of comment.replies ?? []) add(reply, depth + 1)
}
for (const thread of threads) add(thread, 0)
return rows
}
/** `favorites add person 1` is a RECORD; `favorites add person` a TABLE; --type for the rest. */
function targetOf(name: string, recordId: string | undefined, type: string | undefined): CollaborationTarget {
const targetType = (type ?? (recordId !== undefined ? 'RECORD' : 'TABLE')).toUpperCase()
if (!TARGET_TYPES.includes(targetType)) throw new UsageError(`--type must be one of ${TARGET_TYPES.join(', ')}`)
if (targetType === 'RECORD' && recordId === undefined) throw new UsageError('A RECORD favorite needs the record id: favorites add <table> <id>')
return { targetType: targetType as CollaborationTarget['targetType'], targetName: name, ...(targetType === 'RECORD' ? { recordId } : {}) }
}
export function registerCollab(noun: Command, ctx: () => Context): void {
noun.command('presence <table> [pk]')
.description('Who is on a table or record right now. --watch joins (you appear too) and prints each change until Ctrl+C; --observe watches without joining.')
.option('--watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
.option('--observe', 'follow changes without joining')
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
const json = c.format === 'json' || c.format === 'yaml'
const emit = (state: PresenceState) => {
if (json) process.stdout.write(JSON.stringify({ ...state, people: state.people.map((p) => ({ ...p, since: p.since.toISOString() })) }) + '\n')
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
}
if (!options.watch && !options.observe) {
const state = await collaboration.presenceSnapshot(scope)
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
mode: options.editing ? 'EDITING' : 'VIEWING',
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
onChange: (state) => {
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
}
},
})
if (handle.state.error) throw new Error(`presence on ${table}${pk !== undefined ? ` ${pk}` : ''}: ${handle.state.error}`)
if (!json) c.note(options.watch ? 'Joined; Ctrl+C leaves.' : 'Observing; Ctrl+C stops.')
await new Promise<void>((resolve) => {
const leave = () => {
void handle.stop().finally(resolve)
}
process.once('SIGINT', leave)
process.once('SIGTERM', leave)
})
})
const comments = noun.command('comments').description("A record's comment threads.")
comments.command('list <table> <pk>')
.description('Comment threads by latest activity, replies under their thread.')
.option('-l, --limit <n>', 'threads per page (1-50)', '20')
.option('--cursor <token>', 'continue from a previous page')
.option(...COLUMNS_OPTION)
.action(async (table: string, pk: string, options: { limit: string; cursor?: string; columns?: string }) => {
const c = ctx()
const page = await (await collab(c)).comments(table, pk, { limit: positiveInt(options.limit, '--limit'), cursor: options.cursor })
c.print({ data: page, ...chooseColumns(options.columns, c.fields, ['id', 'author', 'at', 'state', 'body'], commentRows(page.threads, c.dateTimeDisplay), page.threads), footer: countFooter(page.threads.length, 'thread', [page.nextCursor && `more: --cursor ${page.nextCursor}`]) })
})
comments.command('add <table> <pk> <body...>')
.description('Post a comment (mentions: @[Name](user:id)); --reply-to answers a thread.')
.option('-r, --reply-to <commentId>', 'reply to this comment')
.option('--field <name>', 'anchor the comment on a field')
.action(async (table: string, pk: string, body: string[], options: { replyTo?: string; field?: string }) => {
const c = ctx()
const comment = await (await collab(c)).addComment(table, pk, body.join(' '), { parentCommentId: options.replyTo ? Number(options.replyTo) : undefined, fieldName: options.field })
c.print({ data: comment, rows: commentRows([comment], c.dateTimeDisplay), columns: ['id', 'author', 'at', 'state', 'body'] })
})
const favorites = noun.command('favorites').description('Your favorites (starred tables, records, apps, processes, reports, saved views).')
favorites.command('list').description('Your favorites in order.').option(...COLUMNS_OPTION).action(async (options: { columns?: string }) => {
const c = ctx()
const items = await (await collab(c)).favorites()
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['id', 'type', 'name', 'record', 'label', 'folder', 'available'], favoriteRows(items), items) })
})
favorites.command('add <name> [recordId]')
.description('Star a record (`add person 1`), a table (`add person`), or --type APP|PROCESS|REPORT|SAVED_VIEW.')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).addFavorite(target)
c.print({ data: { starred: target }, rows: [{ starred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['starred'] })
})
favorites.command('remove <name> [recordId]')
.description('Unstar a target (same arguments as add).')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).removeFavorite(target)
c.print({ data: { unstarred: target }, rows: [{ unstarred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['unstarred'] })
})
const tags = noun.command('tags').description('Tags: the vocabulary of a table, and the tags on a record (add, remove, create).')
const tagRows = (items: TagEntry[]) => items.map((t) => ({ id: t.id ?? '', label: t.label ?? t.name ?? '', color: t.color ?? '', scope: t.tableName ?? 'global', approved: t.approved === false ? 'pending' : 'yes', used: t.usageCount ?? '' }))
/** Tag ids from ids or labels (resolved against the table's vocabulary). */
const tagIds = async (c: Context, table: string, given: string[]): Promise<number[]> => {
const vocabulary = await (await collab(c)).tags(table, undefined, 100)
return given.map((g) => {
if (/^\d+$/.test(g)) return Number(g)
const hit = vocabulary.find((t) => (t.label ?? t.name ?? '').toLowerCase() === g.toLowerCase())
if (!hit?.id) throw new UsageError(`No tag "${g}" for ${table}; \`qqq collab tags list ${table}\` lists them, \`create\` makes one.`)
return hit.id
})
}
tags.command('list <table> [pk]')
.description('The tag vocabulary of a table (search with -q), or the tags on one record.')
.option('-q, --query <text>', 'search the vocabulary')
.option(...COLUMNS_OPTION)
.action(async (table: string, pk: string | undefined, options: { query?: string; columns?: string }) => {
const c = ctx()
const items = pk !== undefined ? await (await collab(c)).recordTags(table, pk) : await (await collab(c)).tags(table, options.query)
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['id', 'label', 'color', 'scope', 'approved', 'used'], tagRows(items), items) })
})
tags.command('add <table> <pk> <tags...>')
.description('Put tags on a record (ids or labels).')
.action(async (table: string, pk: string, given: string[]) => {
const c = ctx()
const items = await (await collab(c)).applyTags(table, pk, { add: await tagIds(c, table, given) })
c.print({ data: items, rows: tagRows(items), columns: ['id', 'label', 'color'] })
})
tags.command('remove <table> <pk> <tags...>')
.description('Take tags off a record (ids or labels).')
.action(async (table: string, pk: string, given: string[]) => {
const c = ctx()
const items = await (await collab(c)).applyTags(table, pk, { remove: await tagIds(c, table, given) })
c.print({ data: items, rows: tagRows(items), columns: ['id', 'label', 'color'] })
})
tags.command('create <table> <label>')
.description('Create a tag for a table (a suggestion when the table takes approved tags only and you may not manage them).')
.option('--color <color>', 'display color')
.action(async (table: string, label: string, options: { color?: string }) => {
const c = ctx()
const tag = await (await collab(c)).createTag(table, label, options.color)
c.print({ data: tag, rows: tagRows([tag]), columns: ['id', 'label', 'color', 'scope', 'approved'] })
})
noun.command('recents')
.description('What you viewed recently, newest first.')
.option('-l, --limit <n>', 'most entries (1-50)', '20')
.option(...COLUMNS_OPTION)
.action(async (options: { limit: string; columns?: string }) => {
const c = ctx()
const items = await (await collab(c)).recents(positiveInt(options.limit, '--limit'))
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['type', 'name', 'record', 'label', 'available'], favoriteRows(items), items) })
})
const followRows = (state: { following: boolean; follow?: Json | null }) => [{ following: state.following, notifyOnChange: state.follow?.notifyOnChange ?? '', notifyOnComment: state.follow?.notifyOnComment ?? '', reason: state.follow?.reason ?? '' }]
noun.command('follow <table> <pk>')
.description('Follow a record (notifications on changes and comments); --status only shows the state.')
.option('--status', 'show whether you follow it, change nothing')
.option('--changes <on|off>', 'notify on changes')
.option('--comments <on|off>', 'notify on comments')
.action(async (table: string, pk: string, options: { status?: boolean; changes?: string; comments?: string }) => {
const c = ctx()
const collaboration = await collab(c)
const flag = (v: string | undefined, name: string) => {
if (v === undefined) return undefined
if (!/^(on|off|true|false)$/i.test(v)) throw new UsageError(`${name} takes on or off`)
return /^(on|true)$/i.test(v)
}
const state = options.status ? await collaboration.followState(table, pk) : await collaboration.follow(table, pk, { notifyOnChange: flag(options.changes, '--changes'), notifyOnComment: flag(options.comments, '--comments') })
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('unfollow <table> <pk>')
.description('Stop following a record.')
.action(async (table: string, pk: string) => {
const c = ctx()
const state = await (await collab(c)).unfollow(table, pk)
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('my-work')
.description('Your open tasks, records assigned to you per table, and unread mentions.')
.action(async () => {
const c = ctx()
const work = await (await collab(c)).myWork()
const rows: Json[] = [
{ item: 'open tasks', value: work.openTaskCount ?? 0 },
{ item: 'unread mentions', value: work.unreadMentionCount ?? 0 },
...Object.entries(work.assignedRecordsByTable ?? {}).map(([table, n]) => ({ item: `assigned: ${table}`, value: n })),
...(work.tasks ?? []).map((t) => ({ item: `task ${t.id ?? ''}${t.dueAt ? ` due ${instantText(t.dueAt, c.dateTimeDisplay)}` : ''}`, value: `${t.title ?? ''}${t.tableName ? ` (${t.tableName} ${t.recordId ?? ''})` : ''}` })),
]
c.print({ data: work, rows, columns: ['item', 'value'] })
})
noun.command('people [query]')
.description('Search the people directory by name or email.')
.option('-l, --limit <n>', 'most people (1-100)', '20')
.option(...COLUMNS_OPTION)
.action(async (query: string | undefined, options: { limit: string; columns?: string }) => {
const c = ctx()
const people = await (await collab(c)).people(query, positiveInt(options.limit, '--limit'))
c.print({ data: people, ...chooseColumns(options.columns, c.fields, ['user', 'name', 'email', 'lastSeen'], people.map((p) => ({ user: p.userId ?? '', name: p.displayName ?? '', email: p.email ?? '', lastSeen: instantText(p.lastSeenAt ?? '', c.dateTimeDisplay) })), people) })
})
}
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Shell completion. The shell scripts are static and small; they call the hidden `qqq __complete`
* with the words typed so far, which answers from the command tree and the cached metadata
* (tables, processes, widgets, fields, operation ids) without touching the network.
*/
import type { Command } from 'commander'
import { OPERATIONS, clearMetaDataCache, getMetaData, readCachedMetaData, readCachedTableMeta } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch, target } from '../session.js'
const SCRIPTS: Record<string, string> = {
bash: `# qqq bash completion: eval "$(qqq completion bash)"
_qqq_complete() {
local IFS=$'\\n'
COMPREPLY=( $(qqq __complete -- "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null) )
}
complete -o default -F _qqq_complete qqq
`,
zsh: `# qqq zsh completion: eval "$(qqq completion zsh)"
_qqq() {
local -a candidates
candidates=("\${(@f)$(qqq __complete -- "\${words[@]:1:$CURRENT-1}" 2>/dev/null)}")
compadd -- "\${candidates[@]}"
}
compdef _qqq qqq
`,
fish: `# qqq fish completion: qqq completion fish | source
complete -c qqq -f -a '(qqq __complete -- (commandline -opc)[2..-1] (commandline -ct))'
`,
pwsh: `# qqq PowerShell completion: qqq completion pwsh | Out-String | Invoke-Expression
Register-ArgumentCompleter -Native -CommandName qqq -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$words = $commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }
if ($wordToComplete -eq '') { $words += '' }
qqq __complete -- @words 2>$null | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
}
`,
}
/** The subcommand a word list points at, and the words after it. */
function locate(root: Command, words: string[]): { command: Command; rest: string[] } {
let command = root
let i = 0
while (i < words.length) {
const w = words[i]!
if (w.startsWith('-')) {
const option = command.options.find((o) => o.long === w || o.short === w) ?? root.options.find((o) => o.long === w || o.short === w)
i += option && option.required ? 2 : 1
continue
}
const next = command.commands.find((c) => c.name() === w || c.aliases().includes(w))
if (!next) break
command = next
i++
}
return { command, rest: words.slice(i) }
}
export function registerCompletion(program: Command, ctx: () => Context): void {
program.command('completion <shell>')
.description('Print a completion script for bash, zsh, fish or pwsh (PowerShell).')
.action((shell: string) => {
const script = SCRIPTS[shell]
if (!script) throw new UsageError(`Unknown shell "${shell}"; use bash, zsh, fish or pwsh.`)
process.stdout.write(script)
})
const cache = program.command('cache').description('The local metadata cache (what completion and listings read).')
cache.command('show').description('Where the cache is and how old it is.').action(() => {
const c = ctx()
const entry = readCachedMetaData(c.server)
c.print({ data: entry ? { server: c.server, fetchedAt: entry.fetchedAt, apiVersion: entry.apiVersion, metaDataGeneration: entry.metaDataGeneration, tables: Object.keys(entry.metaData.tables ?? {}).length, processes: Object.keys(entry.metaData.processes ?? {}).length } : { server: c.server, cached: false } })
})
cache.command('clear').description('Forget the cached metadata (of this server, or --all).').option('--all', 'every server').action((options: { all?: boolean }) => {
const c = ctx()
clearMetaDataCache(options.all ? undefined : c.server)
c.note(options.all ? 'Cleared the metadata cache.' : `Cleared the metadata cache of ${c.server}.`)
})
cache.command('refresh').description('Fetch the metadata again now.').action(async () => {
const c = ctx()
const m = await getMetaData(c.server, authenticatedFetch(c.target), { refresh: true })
c.note(`Cached ${Object.keys(m.tables ?? {}).length} tables, ${Object.keys(m.processes ?? {}).length} processes, ${Object.keys(m.widgets ?? {}).length} widgets for ${c.server}.`)
})
program.command('__complete', { hidden: true })
.description('Completion candidates for the words typed so far.')
.allowUnknownOption()
.allowExcessArguments()
.argument('[words...]')
.action(async (wordsIn: string[]) => {
const words = wordsIn.filter((w) => w !== '--')
const current = words.length ? words[words.length - 1]! : ''
const before = words.slice(0, -1)
const { command, rest } = locate(program, before)
const out = new Set<string>()
const add = (items: Iterable<string>) => { for (const item of items) if (item.startsWith(current)) out.add(item) }
const previous = before[before.length - 1] ?? ''
const meta = () => {
try {
return readCachedMetaData(target().server)?.metaData
} catch {
return undefined
}
}
const tableInWords = () => rest.find((w) => !w.startsWith('-')) ?? before.find((w, i) => i > 0 && !w.startsWith('-') && (before[i - 1] === 'query' || before[i - 1] === 'get' || before[i - 1] === 'create' || before[i - 1] === 'update' || before[i - 1] === 'count' || before[i - 1] === 'describe' || before[i - 1] === 'delete' || before[i - 1] === 'export' || before[i - 1] === 'children' || before[i - 1] === 'download'))
if (current.startsWith('-')) {
add(command.options.filter((o) => !o.hidden).flatMap((o) => [o.long, o.short].filter(Boolean) as string[]))
add(program.options.flatMap((o) => [o.long].filter(Boolean) as string[]))
} else if (['--where', '-w', '--order', '--fields', '-f', '--set', '-s', '--values', '-v'].includes(previous) || (previous === '-f' && command.name() === 'query')) {
const table = tableInWords()
let fields: string[] = []
try {
if (table) fields = Object.keys((readCachedTableMeta<{ fields?: Record<string, unknown> }>(target().server, table)?.fields) ?? {})
} catch {
fields = []
}
add(previous === '--order' ? fields.flatMap((f) => [f, `-${f}`]) : fields)
} else if (previous === '--output' || previous === '-O') {
add(['json', 'table', 'text', 'csv', 'yaml'])
} else if (command.name() === 'api' && rest.length === 0) {
add(OPERATIONS.map((o) => o.id))
} else if (command.commands.length && rest.length === 0) {
add(command.commands.filter((c) => !(c as unknown as { _hidden?: boolean })._hidden).map((c) => c.name()))
} else {
const m = meta()
const parent = command.parent?.name()
const verb = command.name()
if (parent === 'table' || ['query', 'get'].includes(verb)) add(Object.keys(m?.tables ?? {}))
else if (parent === 'process' || verb === 'run') add(Object.keys(m?.processes ?? {}))
else if (parent === 'widget') add(Object.keys(m?.widgets ?? {}))
else if (parent === 'report') add(Object.keys(m?.reports ?? {}))
else if (parent === 'apps') add(Object.keys(m?.apps ?? {}))
else if (parent === 'help' && verb === 'show') add(rest.length === 0 ? ['table', 'process', 'app', 'widget', 'instance'] : Object.keys((rest[0] === 'table' ? m?.tables : rest[0] === 'process' ? m?.processes : rest[0] === 'app' ? m?.apps : rest[0] === 'widget' ? m?.widgets : {}) ?? {}))
else if (verb === 'tables' && previous === '--app') add(Object.keys(m?.apps ?? {}))
else if (verb === 'completion') add(['bash', 'zsh', 'fish', 'pwsh'])
}
process.stdout.write([...out].sort().join('\n') + (out.size ? '\n' : ''))
})
}
+196
View File
@@ -0,0 +1,196 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { AUTH_MODES, PROFILE_NAME, configDir, descriptionOf, editProfile, probeServer, readConfig, readFilePreferences, readTheme, removeProfile, setDefaultProfile, shortNameOf, shortNameProblem, upsertProfile, writeFilePreferences, writeTheme, type AuthMode, type Profile } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { getLogin, removeLogin, secretStoreKind } from '../session.js'
export function registerConfig(program: Command, ctx: () => Context): void {
const config = program.command('config').description('Profiles and local settings.')
config.command('show').description('Where settings live and which profile is in use.').action(async () => {
const c = ctx()
const cfg = readConfig()
const t = c.target
const login = await getLogin(t)
c.print({ data: { configDir: configDir(), secretStore: secretStoreKind(), profile: t.name, chosenBy: t.source, server: t.server, signedIn: login ? (login.kind === 'session' ? `${login.authType.toLowerCase()} session` : 'oauth2') : 'no', defaultProfile: cfg.defaultProfile ?? '', profiles: Object.keys(cfg.profiles).length } })
})
/**
* Local settings, kept in the `ui` section of config.json through the kernel helpers the TUI
* uses: the file preferences (files/navigator.ts) and the theme (session/profiles.ts).
*/
const SETTINGS: Record<string, { help: string; get: () => unknown; set: (value: string | undefined, profile?: string) => unknown }> = {
'download-dir': { help: 'where downloads go without --out / --out-dir (created when missing)', get: () => readFilePreferences().downloadDir, set: (v) => writeFilePreferences({ downloadDir: v === undefined ? undefined : absolute(v) }).downloadDir },
'upload-dir': { help: 'where the TUI\'s upload chooser starts', get: () => readFilePreferences().uploadDir, set: (v) => writeFilePreferences({ uploadDir: v === undefined ? undefined : absolute(v) }).uploadDir },
'download-mode': {
help: 'the TUI: ask (a save dialog each time) or silent (straight to the download directory)',
get: () => readFilePreferences().downloadMode,
set: (v) => {
if (v !== undefined && v !== 'ask' && v !== 'silent') throw new UsageError('download-mode takes ask or silent')
return writeFilePreferences({ downloadMode: v }).downloadMode
},
},
theme: {
help: 'the TUI theme (--profile to set it for one profile; qqq ui lists the themes)',
get: () => readTheme(selectedProfile()),
set: (v, profile) => {
writeTheme(v, { profile })
return v
},
},
}
const absolute = (path: string) => resolve(path.replace(/^~(?=$|[\\/])/, homedir()))
const selectedProfile = () => (program.opts() as { profile?: string }).profile ?? process.env.QQQ_PROFILE
const setting = (name: string) => {
const s = SETTINGS[name]
if (!s) throw new UsageError(`No setting "${name}"; settings: ${Object.keys(SETTINGS).join(', ')}`)
return s
}
config.command('get [setting]')
.description(`A local setting (${Object.keys(SETTINGS).join(', ')}), or all of them.`)
.action((name: string | undefined) => {
const c = ctx()
if (name) {
const value = setting(name).get()
c.print({ data: value ?? null, rows: [{ setting: name, value: value ?? '' }], columns: ['setting', 'value'] })
return
}
const all = Object.fromEntries(Object.entries(SETTINGS).map(([n, s]) => [n, s.get() ?? null]))
c.print({ data: all, rows: Object.entries(SETTINGS).map(([n, s]) => ({ setting: n, value: s.get() ?? '', meaning: s.help })), columns: ['setting', 'value', 'meaning'] })
})
config.command('set <setting> <value>')
.description('Change a local setting (the ui section of config.json, shared with the TUI); an empty value removes it.')
.action((name: string, raw: string) => {
const c = ctx()
const value = setting(name).set(raw === '' ? undefined : raw, name === 'theme' ? (program.opts() as { profile?: string }).profile : undefined)
c.note(value === undefined ? `Removed ${name}.` : `${name}: ${String(value)}`)
})
registerProfileCommands(config.command('profile').description('Named servers: list, add, edit, use, remove (also `qqq profile`).'), ctx)
}
/**
* The profile verbs, registered twice: `qqq profile …` and `qqq config profile …` (1.0.0-rc.1).
* They read and write the same config.json as the TUI's Servers dialog (Ctrl+O), field for field:
* name, short name, description, server, sign-in mode; `use` is the dialog's `*` (default).
*/
export function registerProfileCommands(profile: Command, ctx: () => Context): void {
const authMode = (v: string | undefined): AuthMode | undefined => {
if (v === undefined) return undefined
if (!AUTH_MODES.includes(v as AuthMode)) throw new UsageError(`--auth-mode takes ${AUTH_MODES.join(', ')}`)
return v as AuthMode
}
const short = (v: string | undefined): string | undefined => {
if (v === undefined || v === '') return v
const problem = shortNameProblem(v)
if (problem) throw new UsageError(`--short: ${problem}`)
return v
}
const origin = (server: string): string => {
try {
return new URL(server).origin
} catch {
throw new UsageError(`Not a URL: ${server}`)
}
}
const check = async (c: Context, server: string) => {
const p = await probeServer(server)
if (!p.reachable) throw new UsageError(`${server} did not answer as a QQQ server (${p.error}); leave out --check to save it anyway.`)
c.note(`${server}: UI API ${p.apiVersion}${p.authType ? `, ${p.authType} authentication` : ''}.`)
}
profile.command('list').description('The profiles: name, short name, server, default, sign-in, last used, description.')
.option('--probe', 'ask each server for its UI API version (2.5 s timeout)')
.action(async (options: { probe?: boolean }) => {
const c = ctx()
const cfg = readConfig()
const entries = Object.entries(cfg.profiles)
const probes = options.probe ? await Promise.all(entries.map(([, p]) => probeServer(p.server))) : []
const rows = []
for (const [i, [name, p]] of entries.entries()) {
const login = await getLogin({ name, server: p.server, source: 'option' })
const probe = probes[i]
rows.push({
name, short: shortNameOf(name, p), server: p.server, default: cfg.defaultProfile === name ? '*' : '',
signedIn: login ? (login.kind === 'session' ? 'session' : 'oauth2') : '', authMode: p.authMode ?? 'auto', user: p.user ?? '', lastUsed: p.lastUsedAt ?? '',
...(probe ? { status: probe.reachable ? `UI API ${probe.apiVersion}` : `unreachable: ${probe.error}`, auth: probe.authType ?? '' } : {}),
description: descriptionOf(p), note: p.note ?? '',
})
}
c.print({ data: rows, columns: ['name', 'short', 'server', 'default', 'signedIn', ...(options.probe ? ['status', 'auth'] : []), 'lastUsed', 'description'] })
})
profile.command('add <name> <server>').description('Add a profile for a server, or update it (sign in with `qqq login --profile <name>`).')
.option('--default', 'make it the default')
.option('--short <text>', 'a short name of 2 to 6 characters (footer, Home header); default: from the name')
.option('--description <text>', 'what the server is')
.option('--auth-mode <mode>', `how to sign in to an OAuth2 server: ${AUTH_MODES.join(', ')} (default auto: ask)`)
.option('--note <text>', 'a note (1.0.0-rc.1; shown when there is no description)')
.option('--check', 'refuse unless the server answers /qqq/v1/version')
.action(async (name: string, server: string, options: { default?: boolean; short?: string; description?: string; authMode?: string; note?: string; check?: boolean }) => {
const c = ctx()
if (!PROFILE_NAME.test(name)) throw new UsageError('Profile names are letters, digits, dots, dashes and underscores.')
const o = origin(server)
const fields: Profile = { server: o, note: options.note, short: short(options.short) || undefined, description: options.description, authMode: authMode(options.authMode) }
if (options.check) await check(c, o)
upsertProfile(name, Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as unknown as Profile, { makeDefault: options.default })
c.note(`Profile "${name}" (${shortNameOf(name, readConfig().profiles[name])}) -> ${o}${options.default ? ' (default)' : ''}.`)
})
profile.command('edit <name>').description('Change a profile: server, short name, description, sign-in mode, or its name (an empty value removes a field).')
.option('--server <url>', 'a new server address (sign in again afterwards)')
.option('--short <text>', 'a short name of 2 to 6 characters; empty: back to the default')
.option('--description <text>', 'what the server is; empty removes it')
.option('--auth-mode <mode>', `${AUTH_MODES.join(', ')}`)
.option('--note <text>', 'the 1.0.0-rc.1 note; empty removes it')
.option('--rename <new-name>', 'give the profile a new name (sign in again afterwards)')
.option('--check', 'refuse unless the (new) server answers /qqq/v1/version')
.action(async (name: string, options: { server?: string; short?: string; description?: string; authMode?: string; note?: string; rename?: string; check?: boolean }) => {
const c = ctx()
const current = readConfig().profiles[name]
if (!current) throw new UsageError(`No profile "${name}".`)
const changes: Partial<Profile> = {}
const clear: Array<keyof Profile> = []
if (options.server !== undefined) changes.server = origin(options.server)
for (const [key, value] of [['short', short(options.short)], ['description', options.description], ['note', options.note]] as const) {
if (value === undefined) continue
if (value === '') clear.push(key)
else changes[key] = value
}
if (options.authMode !== undefined) changes.authMode = authMode(options.authMode)
if (options.check) await check(c, changes.server ?? current.server)
try {
editProfile(name, changes, { rename: options.rename, clear })
} catch (e) {
throw new UsageError(e instanceof Error ? e.message : String(e))
}
const now = options.rename ?? name
const p = readConfig().profiles[now]!
c.note(`Profile "${now}" (${shortNameOf(now, p)}) -> ${p.server}${descriptionOf(p) ? ` · ${descriptionOf(p)}` : ''}.`)
})
profile.command('use <name>').description('Make a profile the default (the Servers dialog\'s *).').action((name: string) => {
const c = ctx()
if (!readConfig().profiles[name]) throw new UsageError(`No profile "${name}".`)
setDefaultProfile(name)
c.note(`Default profile: ${name}.`)
})
profile.command('remove <name>').description('Remove a profile and its stored sign-in.').action(async (name: string) => {
const c = ctx()
const cfg = readConfig()
const p = cfg.profiles[name]
if (!p) throw new UsageError(`No profile "${name}".`)
await removeLogin({ name, server: p.server, source: 'option' })
removeProfile(name)
c.note(`Removed profile "${name}".`)
})
}
+195
View File
@@ -0,0 +1,195 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { exportFileName, DeveloperError, blocksToText, callTableApi, testScript, findAssociatedScript, getRecordDeveloper, getScriptLogs, getScriptRevisionFiles, getTableApiDocument, listDeveloperPanels, listTableApis, panelToBlocks, pickRevision, safeFileName, saveDownload, scriptText, storeScript } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import { editText, parsePairs, readJson, type Json } from './shared.js'
import { readFileSync } from 'node:fs'
const values = (record: unknown): Json => (((record as Json | undefined)?.values ?? {}) as Json)
async function guard<T>(work: () => Promise<T>): Promise<T> {
try {
return await work()
} catch (error) {
if (error instanceof DeveloperError) throw new UsageError(error.message)
throw error
}
}
export function registerDeveloper(program: Command, ctx: () => Context): void {
const table = program.commands.find((x) => x.name() === 'table')!
table.command('apis <table>')
.description('The application APIs that expose a table, with their versions.')
.action(async (name: string) => {
const c = ctx()
const apis = await listTableApis(c.server, authenticatedFetch(c.target), name)
const rows = apis.flatMap((a) => (a.versions.length ? a.versions : [{ version: '' }]).map((v) => ({ api: a.name, label: a.label, path: a.path, version: v.version, current: v.version && v.version === a.currentVersion ? 'yes' : '', spec: (v as { tableSpecPath?: string }).tableSpecPath ?? '' })))
c.print({ data: apis, rows, columns: ['api', 'label', 'path', 'version', 'current', 'spec'], footer: apis.length ? '' : `(no application API exposes ${name})` })
})
table.command('api-doc <table> <api> <version>')
.description("A table's OpenAPI document in one API version, saved as JSON.")
.option('--out <file>', 'output file, or - for stdout (default: <table>-<api>-<version>.json, never overwriting)')
.action(async (name: string, api: string, version: string, options: { out?: string }) => {
const c = ctx()
const document = await getTableApiDocument(c.server, authenticatedFetch(c.target), name, api, version)
const text = JSON.stringify(document, null, 2) + '\n'
if (options.out === '-') {
process.stdout.write(text)
return
}
const written = saveDownload(options.out ?? exportFileName(`${name}-${api}-${version}`, 'json'), new TextEncoder().encode(text), { explicit: Boolean(options.out) })
c.note(`wrote ${written} (${text.length} bytes)`)
})
table.command('api-call <table> <api> <version> <method> [path]')
.description("Try a request against one of the table's application APIs (the developer page's playground): METHOD {api path}/{version}/{path} with your session; path defaults to the table's name. Prints the status and the answer.")
.option('-q, --param <name=value...>', 'query parameters')
.option('-b, --body-json <json|file|->', 'a JSON request body')
.action(async (name: string, apiName: string, version: string, method: string, path: string | undefined, options: { param?: string[]; bodyJson?: string }) => {
const c = ctx()
const fetchImpl = authenticatedFetch(c.target)
const apis = await listTableApis(c.server, fetchImpl, name)
const api = apis.find((a) => a.name === apiName || a.label.toLowerCase() === apiName.toLowerCase())
if (!api) throw new UsageError(`${name} has no application API "${apiName}"${apis.length ? ` (APIs: ${apis.map((a) => a.name).join(', ')})` : ' (it exposes none)'}`)
if (api.versions.length && !api.versions.some((v) => v.version === version)) throw new UsageError(`${api.label} has no version ${version} (versions: ${api.versions.map((v) => v.version).join(', ')})`)
if (!/^(GET|POST|PUT|PATCH|DELETE)$/i.test(method)) throw new UsageError(`method: GET, POST, PUT, PATCH or DELETE, not ${method}`)
const result = await callTableApi(c.server, fetchImpl, api, version, method, path ?? name, { query: parsePairs(options.param, '--param'), body: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined })
c.note(`${method.toUpperCase()} ${api.path.replace(/\/+$/, '')}/${version}/${(path ?? name).replace(/^\/+/, '')}: HTTP ${result.status}`)
c.print({ data: result.data })
if (result.status >= 400) process.exitCode = result.status === 401 || result.status === 403 ? 3 : result.status === 404 ? 5 : result.status >= 500 ? 70 : 6
})
table.command('developer <table> <primaryKey>')
.description("A record's developer view: its associated scripts and the developer panels (record security, ESB, …).")
.option('--no-panels', 'only the associated scripts')
.action(async (name: string, primaryKey: string, options: { panels: boolean }) => {
const c = ctx()
const fetchImpl = authenticatedFetch(c.target)
const [developer, panels] = await Promise.all([getRecordDeveloper(c.server, fetchImpl, name, primaryKey), options.panels ? listDeveloperPanels(c.server, fetchImpl, name, primaryKey) : Promise.resolve([])])
if (c.format === 'json' || c.format === 'yaml' || c.options.query) {
c.print({ data: { ...developer, panels } })
return
}
const scripts = developer.scripts.map((s) => ({ field: s.fieldName, script: s.script ? `${String(values(s.script).name ?? '')} (${String(values(s.script).id ?? '')})` : '(none)', revisions: s.revisions.length, latest: s.revisions[0] ? `${String(values(s.revisions[0]).sequenceNo ?? '')}: ${String(values(s.revisions[0]).commitMessage ?? '')}` : '' }))
c.print({ data: developer, rows: scripts, columns: ['field', 'script', 'revisions', 'latest'], footer: `${String(developer.record.recordLabel ?? `${name} ${primaryKey}`)}${scripts.length ? '' : ' (no associated scripts)'}` })
for (const panel of panels) process.stdout.write('\n' + blocksToText(panelToBlocks(panel), { width: process.stdout.columns ?? 100 }) + '\n')
})
const script = program.command('script').description("A record's associated scripts: list, show, store, test, logs.")
script.command('list <table> <primaryKey>')
.description('The associated scripts of a record and their revisions.')
.action(async (name: string, primaryKey: string) => {
const c = ctx()
const developer = await getRecordDeveloper(c.server, authenticatedFetch(c.target), name, primaryKey)
const rows = developer.scripts.flatMap((s) => (s.revisions.length ? s.revisions : [undefined]).map((r, i) => ({
field: i ? '' : s.fieldName,
script: i ? '' : s.script ? String(values(s.script).name ?? values(s.script).id ?? '') : '(none)',
revision: r ? String(values(r).sequenceNo ?? '') : '',
id: r ? String(values(r).id ?? '') : '',
message: r ? String(values(r).commitMessage ?? '') : '',
created: r ? String(values(r).createDate ?? '') : '',
})))
c.print({ data: developer.scripts, rows, columns: ['field', 'script', 'revision', 'id', 'message', 'created'], footer: rows.length ? '' : `(${name} has no associated scripts)` })
})
script.command('show <table> <primaryKey> <field>')
.description('The code of a script revision (the current one, or --revision by sequence number or id).')
.option('--revision <n>', 'a revision sequence number (or id)')
.action(async (name: string, primaryKey: string, field: string, options: { revision?: string }) => {
const c = ctx()
const fetchImpl = authenticatedFetch(c.target)
const developer = await getRecordDeveloper(c.server, fetchImpl, name, primaryKey)
const found = await guard(async () => findAssociatedScript(developer, field))
const revision = await guard(async () => pickRevision(found, options.revision))
if (!revision) throw new UsageError(`${name} ${primaryKey} has no ${field} script yet; \`qqq script store\` creates one.`)
const files = await getScriptRevisionFiles(c.server, fetchImpl, String(values(revision).id))
const text = scriptText(revision, files)
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: { revision: values(revision), files } })
return
}
c.note(`${field}: revision ${String(values(revision).sequenceNo ?? '?')} (id ${String(values(revision).id)})${values(revision).commitMessage ? ` - ${String(values(revision).commitMessage)}` : ''}`)
process.stdout.write(text.endsWith('\n') ? text : text + '\n')
})
script.command('store <table> <primaryKey> <field>')
.description('Store new code for the script a record references (a new revision; the script is created when there is none).')
.option('--file <path>', 'the code, from a file (- for stdin)')
.option('--editor', 'edit the current code in $VISUAL / $EDITOR')
.option('-m, --message <text>', 'commit message')
.action(async (name: string, primaryKey: string, field: string, options: { file?: string; editor?: boolean; message?: string }) => {
const c = ctx()
if (Boolean(options.file) === Boolean(options.editor)) throw new UsageError('Give --file <path> or --editor (one of them).')
const fetchImpl = authenticatedFetch(c.target)
const developer = await getRecordDeveloper(c.server, fetchImpl, name, primaryKey)
const found = await guard(async () => findAssociatedScript(developer, field))
let contents: string
if (options.file) {
contents = readFileSync(options.file === '-' ? 0 : options.file, 'utf8')
} else {
if (!process.stdin.isTTY) throw new UsageError('--editor needs a terminal; use --file.')
const revision = pickRevision(found)
const current = revision ? scriptText(revision, await getScriptRevisionFiles(c.server, fetchImpl, String(values(revision).id))) : ''
contents = editText(current, `${name}-${primaryKey}-${field}.js`)
if (contents === current) {
c.note('No change; nothing was stored.')
return
}
}
const stored = await storeScript(c.server, fetchImpl, name, primaryKey, field, contents, options.message)
c.print({ data: stored, rows: [{ script: `${stored.scriptName} (${stored.scriptId})`, revision: stored.scriptRevisionSequenceNo, id: stored.scriptRevisionId }], columns: ['script', 'revision', 'id'] })
})
script.command('test <table> <primaryKey> <field>')
.description("Run a script's code against test inputs (the developer page's Test tab): the current revision (or --revision, or --file), with the script type's test input fields from -v name=value; prints the output, the log lines and any exception.")
.option('-v, --values <name=value...>', 'test input values')
.option('--revision <n>', 'a revision sequence number (or id)')
.option('--file <path>', 'test this code instead of a stored revision (- for stdin)')
.action(async (name: string, primaryKey: string, field: string, options: { values?: string[]; revision?: string; file?: string }) => {
const c = ctx()
const fetchImpl = authenticatedFetch(c.target)
if (!((await c.metaData() as unknown as Json).processes as Json | undefined)?.testScript) throw new UsageError('This server offers no script tests (the testScript process).')
const developer = await getRecordDeveloper(c.server, fetchImpl, name, primaryKey)
const found = await guard(async () => findAssociatedScript(developer, field))
if (!found.script) throw new UsageError(`${name} ${primaryKey} has no ${field} script yet; \`qqq script store\` creates one.`)
const revision = await guard(async () => pickRevision(found, options.revision))
const files = options.file ? [{ fileName: 'script.js', contents: readFileSync(options.file === '-' ? 0 : options.file, 'utf8') }] : revision ? await getScriptRevisionFiles(c.server, fetchImpl, String(values(revision).id)) : []
const given = parsePairs(options.values, '--values')
const inputNames = found.testInputFields.map((f) => String(f.name))
const unknown = Object.keys(given).filter((k) => inputNames.length && !inputNames.includes(k))
if (unknown.length) throw new UsageError(`Not a test input of this script type: ${unknown.join(', ')} (inputs: ${inputNames.join(', ')})`)
const missing = found.testInputFields.filter((f) => f.isRequired && given[String(f.name)] === undefined).map((f) => String(f.name))
if (missing.length) throw new UsageError(`Give the test inputs: ${missing.map((m) => `-v ${m}=…`).join(' ')}`)
const result = await guard(() => testScript(c.server, fetchImpl, { scriptId: values(found.script).id, apiName: revision ? String(values(revision).apiName ?? '') || undefined : undefined, apiVersion: revision ? String(values(revision).apiVersion ?? '') || undefined : undefined, files, inputs: given }))
if (c.format === 'json' || c.format === 'yaml' || c.options.query) {
c.print({ data: result })
} else {
const outputs = found.testOutputFields.map((f) => ({ output: String(f.label ?? f.name), value: String(result.values[String(f.name)] ?? '') }))
const rows = [...(result.output !== undefined && result.output !== null ? [{ output: 'output', value: typeof result.output === 'object' ? JSON.stringify(result.output) : String(result.output) }] : []), ...outputs]
c.print({ data: result, rows, columns: ['output', 'value'], footer: [...result.logLines.map((l) => `log: ${l.timestamp ? `${l.timestamp} ` : ''}${l.text}`), result.exception ? `exception: ${result.exception}` : ''].filter(Boolean).join('\n') })
}
if (result.exception) process.exitCode = 1
})
script.command('logs <table> <primaryKey> <field> <revisionId>')
.description("A script revision's run logs, newest first, with their lines.")
.action(async (name: string, primaryKey: string, field: string, revisionId: string) => {
const c = ctx()
const logs = await getScriptLogs(c.server, authenticatedFetch(c.target), name, primaryKey, field, revisionId)
const rows = logs.flatMap((log) => (log.lines.length ? log.lines : [undefined]).map((line, i) => ({
log: i ? '' : String(log.id ?? ''),
started: i ? '' : log.startTimestamp,
status: i ? '' : log.hadError ? `error${log.error ? `: ${log.error}` : ''}` : 'ok',
line: line ? `${line.timestamp ? `${line.timestamp} ` : ''}${line.text}` : '',
})))
c.print({ data: logs, rows, columns: ['log', 'started', 'status', 'line'], footer: `(${logs.length} log${logs.length === 1 ? '' : 's'})` })
})
}
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq doctor`: is this client ready to use? The connectivity side (profile, server reachable, UI
* API version within the supported range, authentication type, sign-in accepted, extensions,
* keyring, metadata cache age, and with --conformance the read-only conformance checks) and the
* terminal side (the kernel's checkEnvironment: tmux key bindings, alternate screen, colour,
* unicode, size; --fix applies its live tmux fixes and, after an explicit y, appends them to
* ~/.tmux.conf). Exit 0 when every check passes, 1 with the failing ones listed.
*/
import type { Command } from 'commander'
import { UI_API_VERSION, getAuthenticationMetaData } from '@qrun-io/qqq-ui-api-client'
import { appendTmuxConf, applyLiveFix, callOperation, checkEnvironment, formatReport, readCachedMetaData, type Finding, type Report, type ServerVersion } from '@qrun-io/qqq-core'
import { EXIT, type Context } from '../context.js'
import { CLI_VERSION, authenticatedFetch, currentToken, getLogin, secretStoreKind } from '../session.js'
import type { Json } from './shared.js'
import { UI_API_MIN_VERSION, UI_API_RANGE, buildDescription } from '../version.js'
/** The oldest UI API this client works with. */
export const MIN_UI_API = UI_API_MIN_VERSION
export type CheckStatus = 'ok' | 'warn' | 'fail' | 'skip'
export interface DoctorCheck { area: string; check: string; status: CheckStatus; detail: string }
function compareVersions(a: string, b: string): number {
const pa = a.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0)
const pb = b.split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0)
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0)
return 0
}
/** Whether a server's UI API version is one this client supports. */
export function versionSupport(apiVersion: string | undefined, supportedMajors: number[] | undefined): { status: CheckStatus; detail: string } {
if (!apiVersion) return { status: 'warn', detail: 'the server does not say its UI API version' }
const clientMajor = Number.parseInt(UI_API_VERSION, 10)
if (supportedMajors?.length && !supportedMajors.includes(clientMajor)) return { status: 'fail', detail: `${apiVersion}; the server supports major ${supportedMajors.join(', ')}, this client speaks ${clientMajor}` }
if (compareVersions(apiVersion, MIN_UI_API) < 0) return { status: 'fail', detail: `${apiVersion}; this client supports UI API ${UI_API_RANGE}` }
if (compareVersions(apiVersion, UI_API_VERSION) > 0) return { status: 'warn', detail: `${apiVersion}; supported (${UI_API_RANGE}), newer than this client's document (${UI_API_VERSION}): its new operations need a newer qqq` }
return { status: 'ok', detail: `${apiVersion} (supported: ${UI_API_RANGE}; this client's document: ${UI_API_VERSION})` }
}
/** The connectivity checks, in order; a check that cannot run says why. */
export async function connectivityChecks(c: Context, options: { conformance?: boolean } = {}): Promise<DoctorCheck[]> {
const out: DoctorCheck[] = []
const add = (check: string, status: CheckStatus, detail: string) => out.push({ area: 'server', check, status, detail })
let t
try {
t = c.target
} catch (error) {
add('profile', 'fail', error instanceof Error ? error.message : String(error))
return out
}
add('profile', 'ok', `${t.name} (${t.source}) -> ${t.server}`)
let version: ServerVersion
try {
version = (await callOperation(t.server, 'getVersion', {}, fetch)).data as ServerVersion
add('reachable', 'ok', `${t.server}/qqq/v1/version answered`)
} catch (error) {
add('reachable', 'fail', error instanceof Error ? `${error.message}${(error as { cause?: { message?: string } }).cause?.message ? ` (${(error as { cause?: { message?: string } }).cause!.message})` : ''}` : String(error))
return out
}
const support = versionSupport(version.apiVersion, version.supportedMajorVersions)
add('UI API version', support.status, support.detail)
try {
const auth = await getAuthenticationMetaData(t.server)
add('authentication', 'ok', String(auth.type ?? 'unknown'))
} catch (error) {
add('authentication', 'warn', `could not read the authentication metadata (${error instanceof Error ? error.message : String(error)})`)
}
const login = await getLogin(t).catch((error: unknown) => error as Error)
const token = process.env.QQQ_TOKEN ? 'QQQ_TOKEN' : login instanceof Error ? undefined : login ? (login.kind === 'session' ? `${login.authType.toLowerCase()} session` : 'oauth2 sign-in') : undefined
if (login instanceof Error) add('signed in', 'fail', login.message)
else if (!token) add('signed in', 'fail', `no sign-in for profile "${t.name}"; run qqq login --profile ${t.name}`)
else {
try {
await callOperation(t.server, 'getMetaData', {}, authenticatedFetch(t))
add('signed in', 'ok', `${token}, accepted by the server`)
} catch (error) {
add('signed in', 'fail', `${token}, refused: ${error instanceof Error ? error.message : String(error)}`)
}
}
try {
const registry = await c.extensions({ refresh: true })
const below = registry.installed.filter((e) => e.known && !e.meetsMinimum)
add('extensions', below.length ? 'warn' : 'ok', `${registry.describe()}${below.length ? `; below this client's minimum: ${below.map((e) => e.name).join(', ')}` : ''}`)
} catch (error) {
add('extensions', 'warn', error instanceof Error ? error.message : String(error))
}
out.push({ area: 'local', check: 'keyring', status: 'ok', detail: secretStoreKind() })
const cached = readCachedMetaData(t.server)
out.push({ area: 'local', check: 'metadata cache', status: 'ok', detail: cached ? `fetched ${Math.round((Date.now() - Date.parse(cached.fetchedAt)) / 1000)} s ago (qqq cache refresh rereads it)` : 'empty (filled by the next command)' })
if (options.conformance) {
const name = '@qrun-io/qqq-ui-api-conformance'
let suite: { runConformance: (o: { baseUrl: string; token?: string }) => Promise<Array<{ name: string; ok: boolean; skipped?: boolean; detail?: string }>> } | undefined
try {
suite = await import(name) as typeof suite
} catch {
out.push({ area: 'conformance', check: 'suite', status: 'fail', detail: `${name} is not installed (npm install -g ${name})` })
}
if (suite) {
for (const r of await suite.runConformance({ baseUrl: t.server, token: await currentToken(t).catch(() => undefined) })) {
out.push({ area: 'conformance', check: r.name, status: r.skipped ? 'skip' : r.ok ? 'ok' : 'fail', detail: r.detail ?? '' })
}
}
}
return out
}
/** The terminal side: the kernel's checkEnvironment (tmux bindings, alternate screen, colour, unicode, size). */
function terminalChecks(): { checks: DoctorCheck[]; report: Report } {
const report = checkEnvironment({ columns: process.stdout.columns, rows: process.stdout.rows })
const checks = report.findings.map((f): DoctorCheck => ({ area: 'terminal', check: f.id, status: f.level === 'warn' ? 'warn' : 'ok', detail: `${f.message}${f.advice ? ` (${f.advice})` : ''}${f.live ? ' [--fix]' : ''}` }))
if (!checks.length) checks.push({ area: 'terminal', check: report.terminal || 'terminal', status: 'ok', detail: `nothing to report${report.inTmux ? ' (in tmux)' : ''}` })
return { checks, report }
}
/**
* --fix: each live tmux fix after its own question (--yes answers them), then the ~/.tmux.conf
* append only after an explicit y typed at the terminal (--yes does not answer that one).
*/
async function applyFixes(c: Context, report: Report): Promise<void> {
const fixable = report.findings.filter((f) => f.live?.length)
if (!fixable.length) {
c.note('No terminal fixes to apply.')
return
}
const applied: Finding[] = []
for (const finding of fixable) {
if (!await c.confirm(`${finding.message} Fix it for this tmux server now?`)) continue
applyLiveFix(finding)
applied.push(finding)
c.note(` fixed for this tmux server: ${finding.id}`)
}
const permanent = applied.filter((f) => f.permanent)
if (!permanent.length) return
if (!process.stdin.isTTY) {
c.note('Not made permanent: that needs an explicit y at a terminal (run qqq doctor --fix there).')
return
}
const { ask } = await import('../prompt.js')
const answer = await ask(`Make ${permanent.length === 1 ? 'it' : 'them'} permanent (append to ~/.tmux.conf)? [y/N] `)
if (/^y(es)?$/i.test(answer)) c.note(` appended to ${appendTmuxConf(permanent)}`)
}
export function registerDoctor(program: Command, ctx: () => Context): void {
program.command('doctor')
.description('Check this client: server reachable, UI API version, sign-in, extensions, keyring, cache, and the terminal (tmux keys, colour, size). Exit 1 lists what is wrong.')
.option('--conformance', 'also run the read-only conformance checks against the server')
.option('--fix', 'apply the terminal fixes the report offers (asks first)')
.action(async (options: { conformance?: boolean; fix?: boolean }) => {
const c = ctx()
const terminal = terminalChecks()
const build: DoctorCheck = { area: 'local', check: 'build', status: 'ok', detail: buildDescription() }
const checks = [build, ...await connectivityChecks(c, options), ...terminal.checks]
const failed = checks.filter((x) => x.status === 'fail')
c.print({
data: { client: CLI_VERSION, build: build.detail, uiApi: UI_API_VERSION, ok: failed.length === 0, checks },
rows: checks as unknown as Json[],
columns: ['area', 'check', 'status', 'detail'],
footer: failed.length ? `(${failed.length} of ${checks.length} checks failed: ${failed.map((x) => x.check).join(', ')})` : `(all ${checks.length} checks passed${checks.some((x) => x.status === 'warn') ? '; see the warnings' : ''})`,
})
if (c.format === 'table' && terminal.report.findings.length) c.note(`\n${formatReport(terminal.report)}`)
if (options.fix) await applyFixes(c, terminal.report)
if (failed.length) process.exitCode = EXIT.error
})
}
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* One noun per extension (qbit or module): `qqq esb ...`, `qqq collab ...`, `qqq sql-workbench ...`.
* Every operation of an extension is a subcommand, generated from the operation index
* (`get-overview`, `put-favorites`, `get-records-comments <table> <pk>`), taking its path parameters
* as arguments or --path, plus --param and --body-json. First-class commands (esb, collab) sit next
* to the generated ones. Every subcommand checks the server's GET /version first: an extension that
* is not installed stops with exit 5 and the list of what is installed.
*/
import type { Command } from 'commander'
import { EsbTriggerError, KNOWN_EXTENSIONS, OPERATIONS, esbTriggersOf, operationsOf, resolveEsbTrigger, type OperationInfo } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { runOperation, type OperationOptions } from './api.js'
import { countFooter, instantText, plural, type Json } from './shared.js'
import type { DisplayOptions } from '@qrun-io/qqq-core'
import { registerCollab } from './collab.js'
import { registerAssistant } from './assistant.js'
/** The noun of each extension; the extension's own name unless it is long. */
export const NOUNS: Record<string, string> = { collaboration: 'collab' }
export function nounOf(extension: string): string {
return NOUNS[extension] ?? extension
}
function kebab(text: string): string {
return text.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[^A-Za-z0-9]+/g, '-').toLowerCase()
}
/**
* Subcommand names for an extension's operations: the method and the literal path segments after
* the extension's prefix (`GET /qbit/collaboration/records/{table}/{pk}/comments` is
* `get-records-comments`); names that would collide get `-by-<param>`, then the operationId.
*/
export function subcommandNames(extension: string, pathPrefix: string, operations: OperationInfo[]): Map<string, OperationInfo> {
const base = `/qqq/v1${pathPrefix}`
const first = operations.map((op) => {
const segments = op.path.startsWith(base) ? op.path.slice(base.length).split('/').filter(Boolean) : op.path.split('/').filter(Boolean)
const literals = segments.filter((s) => !s.startsWith('{'))
return { op, name: `${op.method.toLowerCase()}-${literals.length ? literals.map(kebab).join('-') : extension}` }
})
const count = (name: string, list: { name: string }[]) => list.filter((x) => x.name === name).length
const second = first.map((x) => (count(x.name, first) > 1 && x.op.pathParams.length ? { op: x.op, name: `${x.name}-by-${x.op.pathParams.map(kebab).join('-')}` } : x))
const names = new Map<string, OperationInfo>()
for (const x of second) names.set(count(x.name, second) > 1 ? kebab(x.op.id) : x.name, x.op)
return names
}
/** Adds one generated subcommand per operation of an extension to its noun. */
export function addOperationCommands(noun: Command, extension: string, pathPrefix: string, ctx: () => Context): void {
const taken = new Set(noun.commands.map((c) => c.name()))
for (const [name, op] of subcommandNames(extension, pathPrefix, operationsOf(extension))) {
if (taken.has(name)) continue
const args = op.pathParams.map((p) => ` [${p}]`).join('')
const cmd = noun.command(`${name}${args}`)
.description(`${op.summary || op.id} (${op.method} ${op.path.replace('/qqq/v1', '')}; operation ${op.id})`)
.option('--path <name=value...>', 'path parameters (or give them as arguments)')
.option('-q, --param <name=value...>', `query parameters${op.queryParams.length ? `: ${op.queryParams.join(', ')}` : ''}`)
if (op.bodyTypes.includes('application/json') || op.bodyTypes.length === 0) cmd.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
if (op.bodyTypes.includes('multipart/form-data')) cmd.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
cmd.option('-o, --out <file>', 'write a binary response to a file (- for stdout)')
cmd.option('--columns <names>', 'table output: the columns of the result\'s lists, comma-separated (nested fields as a.b)')
cmd.action(async (...actionArgs: unknown[]) => {
const values = actionArgs.slice(0, op.pathParams.length) as (string | undefined)[]
const options = actionArgs[op.pathParams.length] as OperationOptions
const pathValues: Record<string, string> = {}
op.pathParams.forEach((p, i) => {
if (values[i] !== undefined) pathValues[p] = values[i]!
})
await runOperation(ctx(), op, options, pathValues)
})
}
}
/** Requires the extension on the target server (exit 5 with the installed list when it is absent). */
export async function requireExtension(c: Context, name: string): Promise<void> {
;(await c.extensions()).require(name)
}
function counters(destination: Json | undefined, display?: DisplayOptions): Json {
const c = (destination?.counters ?? {}) as Json
return { published: c.published ?? '', consumed: c.consumed ?? '', failed: c.failed ?? '', deadLettered: c.deadLettered ?? '', inFlight: c.inFlight ?? '', lastActivity: instantText(c.lastActivity ?? '', display) }
}
function registerEsb(noun: Command, ctx: () => Context): void {
const run = async (id: string, path: Record<string, string> = {}, query: Record<string, string | undefined> = {}) => {
const c = ctx()
await requireExtension(c, 'esb')
return { c, data: (await c.call(id, { path, query })).data as Json }
}
const columns = ['role', 'destination', 'type', 'detail', 'state', 'published', 'consumed', 'failed', 'deadLettered', 'inFlight', 'lastActivity']
const linkRows = (data: Json, display?: DisplayOptions) => [
...((data.publications ?? []) as Json[]).map((p) => {
const d = p.destination as Json | undefined
return { role: 'publishes', destination: d?.name ?? '', type: d?.type ?? '', detail: ((p.events ?? []) as string[]).join(' '), state: '', ...counters(d, display) }
}),
...((data.subscribers ?? data.triggers ?? []) as Json[]).map((s) => {
const d = s.destination as Json | undefined
return { role: 'trigger', destination: d?.name ?? '', type: d?.type ?? '', detail: s.processLabel ?? s.processName ?? s.name ?? '', state: s.state ?? '', ...counters(d, display) }
}),
]
noun.command('overview')
.description('Broker connections and every destination with its publishers and triggers (needs ESB permission).')
.action(async () => {
const { c, data } = await run('getEsbOverview')
const rows = ((data.destinations ?? []) as Json[]).map((d) => ({ destination: d.name, type: d.type, provider: d.provider, publishers: ((d.publishers ?? []) as unknown[]).length, triggers: ((d.triggers ?? []) as unknown[]).length, ...counters(d, c.dateTimeDisplay) }))
c.print({ data, rows, columns: ['destination', 'type', 'provider', 'publishers', 'triggers', 'published', 'consumed', 'failed', 'deadLettered', 'lastActivity'], footer: countFooter(rows.length, 'destination', [plural(((data.providers ?? []) as unknown[]).length, 'provider')]) })
})
noun.command('table <table>')
.description('What a table publishes and which processes it triggers.')
.action(async (table: string) => {
const { c, data } = await run('getEsbTableByTable', { table })
c.print({ data, rows: linkRows(data, c.dateTimeDisplay), columns })
})
noun.command('process <process>')
.description('What a process publishes and what triggers it.')
.action(async (process: string) => {
const { c, data } = await run('getEsbProcessByProcess', { process })
c.print({ data, rows: linkRows(data, c.dateTimeDisplay), columns })
})
const messageRows = (data: Json, display?: DisplayOptions) => ((data.messages ?? []) as Json[]).map((m) => ({ messageId: m.messageId, timestamp: instantText(m.timestamp ?? '', display), deliveries: m.deliveryCount ?? '', event: m.event ?? '', body: String(m.rawBody ?? '').slice(0, 80) }))
noun.command('dead-letters <trigger>')
.description("A trigger's dead letters.")
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (trigger: string, options: { limit: string; offset?: string }) => {
const { c, data } = await run('getEsbDeadLettersByTrigger', { trigger }, { limit: options.limit, offset: options.offset })
const rows = messageRows(data, c.dateTimeDisplay)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: countFooter(rows.length, 'dead letter', [Boolean(data.hasMore) && 'more with --offset']) })
})
noun.command('messages <destination>')
.description('Messages waiting on a destination (a topic needs --trigger: its messages are kept per trigger; the trigger name, e.g. syncOrder.orderEvents, or its process name).')
.option('-t, --trigger <name>', 'the trigger (subscription) of a topic, or the process it triggers')
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (destination: string, options: { trigger?: string; limit: string; offset?: string }) => {
const c = ctx()
await requireExtension(c, 'esb')
const read = (trigger?: string) => c.call('getEsbMessagesByDestination', { path: { destination }, query: { trigger, limit: options.limit, offset: options.offset } })
let result
try {
result = await read(options.trigger)
} catch (error) {
// a topic without --trigger (400) or with a process name instead of the trigger name (404):
// find the trigger from the overview, or name the valid ones
if (!(error instanceof UiApiError) || (error.code !== 'BAD_REQUEST' && error.code !== 'NOT_FOUND')) throw error
const overview = (await c.call('getEsbOverview', {})).data
const triggers = esbTriggersOf(overview, destination)
if (!triggers.length && error.code === 'NOT_FOUND') throw error
let trigger: string
try {
trigger = resolveEsbTrigger(triggers, destination, options.trigger)
} catch (resolveError) {
if (resolveError instanceof EsbTriggerError) throw new UsageError(resolveError.message)
throw resolveError
}
if (trigger === options.trigger) throw error
c.note(`trigger ${trigger} (of process ${options.trigger})`)
result = await read(trigger)
}
const data = result.data as Json
const rows = messageRows(data, c.dateTimeDisplay)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: countFooter(rows.length, 'message', [Boolean(data.hasMore) && 'more with --offset']) })
})
}
export function registerExtensions(program: Command, ctx: () => Context): void {
program.command('extensions')
.description('Extensions (qbits) this client knows and which ones the server has installed.')
.action(async () => {
const c = ctx()
const registry = await c.extensions()
const rows: Json[] = KNOWN_EXTENSIONS.map((e) => {
const installed = registry.installed.find((i) => i.name === e.name)
return {
extension: e.name,
noun: `qqq ${nounOf(e.name)}`,
installed: installed ? installed.version ?? 'yes' : '',
status: !installed ? 'not installed' : installed.meetsMinimum ? 'ok' : `needs ${e.minVersion}+`,
client: e.documentVersion ?? '',
minimum: e.minVersion ?? '',
operations: operationsOf(e.name).length,
channels: e.channels.join(' '),
}
})
for (const u of registry.unknown()) rows.push({ extension: u.name, noun: '', installed: u.version ?? 'yes', status: 'unknown to this client', client: '', minimum: '', operations: 0, channels: '' })
c.print({ data: { installed: registry.installed, known: KNOWN_EXTENSIONS }, rows, columns: ['extension', 'noun', 'installed', 'status', 'client', 'minimum', 'operations', 'channels'], footer: `(installed: ${registry.describe()}; ${OPERATIONS.filter((o) => o.extension).length} extension operations in this client)` })
})
for (const e of KNOWN_EXTENSIONS) {
const name = nounOf(e.name)
const noun = program.command(name).description(`${e.label} (${e.name} extension${e.minVersion ? ` ${e.minVersion}+` : ''}, ${e.repository}): ${e.description || 'operations'}`)
if (name !== e.name) noun.alias(e.name)
if (e.name === 'esb') registerEsb(noun, ctx)
if (e.name === 'collaboration') registerCollab(noun, ctx)
if (e.name === 'assistant') registerAssistant(noun, ctx)
addOperationCommands(noun, e.name, e.pathPrefix, ctx)
noun.addHelpText('after', `\nEvery ${e.name} operation is a subcommand; path parameters go as arguments or --path name=value.\nNot installed on the server: exit 5 with the installed list (see \`qqq extensions\`).`)
}
}
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { helpToText, metaHelp, processHelp, tableHelp, type HelpEntry } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import type { Json } from './shared.js'
/**
* `qqq help show …`: the help content the application's metadata carries, as text. `qqq help
* [command]` still prints the CLI's own help.
*/
export function registerHelp(program: Command, ctx: () => Context): void {
program.helpCommand(false)
const help = program.command('help [command...]')
.description('Help: `qqq help <command>` for the CLI, `qqq help show …` for the application\'s help content.')
.action((words: string[]) => {
let command: Command = program
for (const word of words ?? []) {
const next = command.commands.find((x) => x.name() === word || x.aliases().includes(word))
if (!next) throw new UsageError(`No command "${words.join(' ')}"`)
command = next
}
command.outputHelp()
})
help.command('show <kind> [name] [field]')
.description('Application help content: `table <table> [field]`, `process <process>`, `app <app>`, `widget <widget>`, or `instance`. Text siblings when the server sent them, else HTML stripped.')
.option('--role <role>', 'only entries for this screen role (READ_SCREENS, EDIT_SCREEN, …) and those for every screen')
.action(async (kind: string, name: string | undefined, field: string | undefined, options: { role?: string }) => {
const c = ctx()
const need = () => {
if (!name) throw new UsageError(`help show ${kind} needs a name`)
return name
}
let entries: HelpEntry[]
let subject: string
if (kind === 'table') {
const meta = await c.tableMeta<Json>(need())
try {
entries = tableHelp(meta, { field, role: options.role })
} catch (error) {
throw new UsageError(error instanceof Error ? error.message : String(error))
}
subject = `${meta.label}${field ? ` / ${String(((meta.fields ?? {}) as Record<string, Json>)[field]?.label ?? field)}` : ''}`
} else if (kind === 'process') {
const meta = await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName: need() } } })) as Json
entries = processHelp(meta, { role: options.role })
subject = String(meta.label ?? name)
} else if (kind === 'app' || kind === 'widget' || kind === 'instance') {
const metaData = await c.metaData() as unknown as Json
const meta = kind === 'instance' ? metaData : ((metaData[kind === 'app' ? 'apps' : 'widgets'] ?? {}) as Record<string, Json>)[need()]
if (!meta) throw new UsageError(`No ${kind} named ${name}`)
entries = metaHelp(kind, meta, { role: options.role })
subject = String(meta.label ?? name ?? 'instance')
} else {
throw new UsageError('help show takes table, process, app, widget or instance')
}
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: entries })
return
}
process.stdout.write((entries.length ? helpToText(entries) : `${subject}: no help content in the metadata.`) + '\n')
})
}
+178
View File
@@ -0,0 +1,178 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq history`: everything you did with the CLI and the TUI, like a shell history (the activity
* log of core/session/activity-log.ts). Also where main.ts records each CLI invocation.
*/
import type { Command } from 'commander'
import { ACTIVITY_KINDS, NO_PROFILE, OUTPUT_FORMATS, REDACTED, absoluteTime, appendActivity, clearActivity, cliKind, commandWords, filterActivity, formatTable, isMutatingCommand, joinCommandLine, parseSince, readActivity, relativeTime, render, resolveProfile, splitCommandLine, type ActivityEntry, type OutputFormat } from '@qrun-io/qqq-core'
import { UsageError, io, type Context } from '../context.js'
import { countFooter, intOption } from './shared.js'
/**
* What main.ts records for this invocation: `history rerun` replaces the argv with the command it
* ran (as a shell records the expanded command, not `!5`); `history clear` is not recorded.
*/
export const invocation: { argv?: string[]; skip?: boolean } = {}
/** Commands never recorded: shell completion asks on every Tab. */
const UNRECORDED = new Set(['__complete'])
/** The profile and server a CLI invocation belongs to (the global options, QQQ_PROFILE, the default), or `none`. */
export function invocationTarget(options: { profile?: string; server?: string }): { profile: string; server?: string } {
try {
const t = resolveProfile(options)
return { profile: t.name, server: t.server }
} catch {
return { profile: options.profile ?? process.env.QQQ_PROFILE ?? NO_PROFILE }
}
}
/** The argv without --server, --profile and -p (and their values). */
export function withoutTarget(argv: string[]): string[] {
const out: string[] = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!
if (a === '--server' || a === '--profile' || a === '-p') {
i++
continue
}
if (/^--(server|profile)=/.test(a)) continue
out.push(a)
}
return out
}
/** Records one CLI invocation (main.ts calls this once, on exit): the argv as typed, redacted, with exit code and duration. */
export function recordInvocation(argv: string[], options: { profile?: string; server?: string }, exit: number, startedAt: number): void {
if (invocation.skip) return
const args = invocation.argv ?? argv
const [first] = commandWords(args)
if (first && UNRECORDED.has(first)) return
const command = `qqq ${joinCommandLine(args)}`.trim()
// the summary leaves out the target options: the entry records the profile and server already
const summary = `qqq ${joinCommandLine(withoutTarget(args))}`.trim()
appendActivity({ ...invocationTarget(options), client: 'cli', kind: cliKind(args), summary, command, exit, ms: Date.now() - startedAt })
}
interface ListOptions {
limit: string
since?: string
grep?: string
kind?: string[]
allProfiles?: boolean
absolute?: boolean
commands?: boolean
format?: string
}
/** The rows of the listing (also the TUI's History screen). */
export function historyRows(entries: ActivityEntry[], options: { absolute?: boolean; commands?: boolean; allProfiles?: boolean; now?: Date } = {}) {
return entries.map((e) => ({
n: e.n,
time: options.absolute ? absoluteTime(e.at) : relativeTime(e.at, options.now),
...(options.allProfiles ? { profile: e.profile } : {}),
client: e.client,
kind: e.kind,
summary: e.summary + (e.exit ? ` [exit ${e.exit}]` : ''),
...(options.commands ? { command: e.command ?? '' } : {}),
}))
}
function target(c: () => Context, allProfiles?: boolean): { profile?: string; allProfiles?: boolean } {
if (allProfiles) return { allProfiles: true }
let profile = NO_PROFILE
try {
profile = c().target.name
} catch {
// no server configured yet: the log of `none`
}
return { profile }
}
export function registerHistory(program: Command, ctx: () => Context): void {
const list = (options: ListOptions) => {
const c = ctx()
const format = (options.format ?? c.format) as OutputFormat
if (!OUTPUT_FORMATS.includes(format)) throw new UsageError(`--format must be one of ${OUTPUT_FORMATS.join(', ')}`)
const kinds = options.kind?.flatMap((k) => k.split(',')).map((k) => k.trim()).filter(Boolean)
for (const k of kinds ?? []) if (k !== 'ui' && !(ACTIVITY_KINDS as readonly string[]).includes(k)) throw new UsageError(`--kind takes ui or ${ACTIVITY_KINDS.join(', ')}: ${k}`)
let since: Date | undefined
try {
since = options.since ? parseSince(options.since) : undefined
} catch (e) {
throw new UsageError((e as Error).message)
}
const all = readActivity(target(ctx, options.allProfiles))
const limit = intOption(options.limit, '--limit', 0)
const shown = filterActivity(all, { since, grep: options.grep, kind: kinds, limit })
const rows = historyRows(shown, { absolute: options.absolute, commands: options.commands, allProfiles: options.allProfiles })
const columns = Object.keys(rows[0] ?? { n: 0, time: '', client: '', kind: '', summary: '', ...(options.commands ? { command: '' } : {}) })
const headers = columns.map((k) => (k === 'n' ? '#' : k[0]!.toUpperCase() + k.slice(1)))
const hidden = all.length - shown.length
const footer = countFooter(shown.length, 'entry', [hidden > 0 && `${hidden} older or filtered out; --limit 0 shows all`, !options.commands && shown.some((e) => e.command && e.command !== e.summary) && '--commands shows the command of each', shown.length > 0 && '`qqq history rerun <#>` runs one again'], 'entries')
// table output: the summary gets more room than the usual 60-character cells, --commands shows each command in full
const text = format === 'table' && !c.options.query
? `${formatTable(rows, columns, headers, options.commands ? 100_000 : 90)}\n${footer}`
: render({ data: shown, rows, columns, headers, footer }, format, c.options.query, { width: io.width() })
io.out(text + '\n')
}
const history = program.command('history')
.description('Everything you did with the CLI and the TUI, like a shell history: number, time, client, summary; newest last. `history rerun <n>` runs a line again, `history clear` empties it.')
.option('-l, --limit <n>', 'the last n entries (0: all)', '50')
.option('--since <when>', 'only entries since a duration ago (30m, 2h, 3d, 1w) or a date (2026-11-01)')
.option('-g, --grep <text>', 'only entries whose summary or command contains the text (any case)')
.option('-k, --kind <kind...>', `only these kinds: cli, ui (the client) or ${ACTIVITY_KINDS.filter((k) => k !== 'cli').join(', ')}`)
.option('--all-profiles', 'every profile\'s log, merged by time (default: the profile in use, --profile chooses)')
.option('--absolute', 'absolute times (2026-11-01 14:03:22) instead of relative ones')
.option('--commands', 'add the command that does each entry again, in full')
.option('--format <format>', 'json, table, text, csv or yaml (as --output)')
.addHelpText('after', '\nThe log is one JSONL file per profile: ~/.local/state/qqq/activity/<profile>.jsonl (QQQ_STATE_DIR moves it; rotated once to .1 at 5 MB).\nPasswords, tokens, PINs and secret values are never written. QQQ_HISTORY=off stops recording.\n')
.action(list)
history.command('rerun <n>')
.option('--all-profiles', 'number as `qqq history --all-profiles` does')
.description('Run entry n again (its CLI command; a TUI entry runs its CLI equivalent). Asks first when the command changes data (--yes skips).')
.action(async (n: string, opts: { allProfiles?: boolean }, cmd: Command) => {
const c = ctx()
const parent = { ...cmd.parent!.opts<ListOptions>(), ...(opts.allProfiles ? { allProfiles: true } : {}) }
const number = intOption(n, '<n>', 1)!
const entry = readActivity(target(ctx, parent.allProfiles)).find((e) => e.n === number)
if (!entry) throw new UsageError(`No history entry ${number} (\`qqq history\` lists them)`)
const command = entry.command
if (!command) throw new UsageError(`Entry ${number} (${entry.summary}) has no command to run again`)
if (command.startsWith(':')) throw new UsageError(`Entry ${number} is a TUI command (${command}); run it on the : line of qqq ui`)
if (command.includes(REDACTED)) throw new UsageError(`Entry ${number} had a secret that was not logged; type the command again: ${command}`)
const argv = splitCommandLine(command.replace(/^qqq\s+/, ''))
const [first, second] = commandWords(argv)
if (first === 'history' && second === 'rerun') throw new UsageError('That entry is a history rerun itself')
if (isMutatingCommand(argv) && !(await c.confirm(`Run again: ${command}? It changes data.`))) {
c.note('not run')
return
}
c.note(command)
invocation.argv = argv
const { createProgram } = await import('../program.js')
await createProgram().parseAsync(argv, { from: 'user' })
})
history.command('clear')
.option('--all-profiles', 'every profile\'s history')
.description('Empty the history of the profile in use (--all-profiles: every profile). Asks first (--yes skips).')
.action(async (opts: { allProfiles?: boolean }, cmd: Command) => {
const c = ctx()
const parent = { ...cmd.parent!.opts<ListOptions>(), ...(opts.allProfiles ? { allProfiles: true } : {}) }
const which = target(ctx, parent.allProfiles)
const count = readActivity(which).length
if (!count) return c.note('The history is empty.')
if (!(await c.confirm(`Clear ${count} history entr${count === 1 ? 'y' : 'ies'} of ${which.allProfiles ? 'every profile' : `profile ${which.profile}`}?`))) return c.note('not cleared')
clearActivity(which)
invocation.skip = true
c.note(`cleared ${count} entr${count === 1 ? 'y' : 'ies'}`)
})
}
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import type { Context } from '../context.js'
import { countFooter, instantText, positiveInt, type Json } from './shared.js'
function noJobs(error: unknown): never {
if (error instanceof UiApiError && error.status === 404) throw new Error('This server has no jobs operations (durable jobs are not enabled on it).')
throw error
}
export function registerJob(program: Command, ctx: () => Context): void {
const job = program.command('job').description('Jobs (durable and tracked processes): list, get, logs, cancel, retry.')
job.command('list')
.description('List your jobs (active by default; --all for every state).')
.option('--all', 'all states')
.option('-l, --limit <n>', 'most jobs', '50')
.action(async (options: { all?: boolean; limit: string }) => {
const c = ctx()
const result = await c.call('listJobs', { query: { scope: 'mine', state: options.all ? undefined : 'active', limit: String(positiveInt(options.limit, '--limit')) } }).catch(noJobs)
const data = result.data as Json
const jobs = ((data.jobs ?? []) as Json[]).map((j) => ({ id: j.jobId, label: j.label ?? j.processName, status: j.status, percent: j.percent ?? '', message: j.message ?? '' }))
c.print({ data, rows: jobs, columns: ['id', 'label', 'status', 'percent', 'message'], footer: countFooter(jobs.length, 'job', [`${data.activeCount ?? 0} active`]) })
})
job.command('get <jobId>')
.description('One job with its phases.')
.action(async (jobId: string) => {
const c = ctx()
const result = await c.call('getJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data })
})
job.command('logs <jobId>')
.description("A job's progress log; --follow keeps reading until it ends.")
.option('-f, --follow', 'keep following')
.action(async (jobId: string, options: { follow?: boolean }) => {
const c = ctx()
let afterLogId: string | undefined
for (;;) {
const result = await c.call('getJobProgress', { path: { jobId }, query: { afterLogId, logLimit: '200' } }).catch(noJobs)
const data = result.data as Json
const lines = (data.log ?? data.lines ?? []) as Json[]
for (const line of lines) {
console.log(`${instantText(line.timestamp ?? line.at ?? '', c.dateTimeDisplay)} ${line.level ?? 'info'} ${line.message ?? ''}`.trim())
afterLogId = String(line.id ?? line.logId ?? afterLogId ?? '')
}
const status = String(data.status ?? data.state ?? '')
if (!options.follow || /complete|error|fail|cancel/i.test(status)) {
if (status) c.note(`status: ${status}`)
break
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
})
job.command('cancel <jobId>').description('Cancel a job.').action(async (jobId: string) => {
const c = ctx()
const result = await c.call('cancelJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data ?? {}, rows: [{ cancelled: jobId }], columns: ['cancelled'] })
})
job.command('retry <jobId>').description('Retry a failed job.').action(async (jobId: string) => {
const c = ctx()
const result = await c.call('retryJob', { path: { jobId } }).catch(noJobs)
c.print({ data: result.data ?? {}, rows: [{ retried: jobId }], columns: ['retried'] })
})
program.command('jobs', { hidden: true }).description('Alias of `job list`.').option('--all').action(async (options: { all?: boolean }) => { await job.commands.find((x) => x.name() === 'list')!.parseAsync(options.all ? ['--all'] : [], { from: 'user' }) })
}
+153
View File
@@ -0,0 +1,153 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { openRealtimeStream, unwrap } from '@qrun-io/qqq-ui-api-client'
import { plainSearchText, QUICK_SEARCH_SYNTAX, groupSearchResults, unsupportedSyntax, appHome, blocksToText, callOperation, findNavigationTargets, formatTable, homePage, loadDashboard, type NavigationTarget } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { CLIENT_CHANNEL, authenticatedFetch, currentToken, sessionCookieHeaderFor } from '../session.js'
import { countFooter, positiveInt, type Json } from './shared.js'
export function registerMisc(program: Command, ctx: () => Context): void {
const listApps = async () => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
const apps = Object.values((metaData.apps ?? {}) as Record<string, Json>)
.map((a) => ({ name: a.name, label: a.label, items: (a.children as unknown[] | undefined)?.length ?? 0, widgets: (a.widgets as unknown[] | undefined)?.length ?? 0 }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
c.print({ data: apps, columns: ['name', 'label', 'items', 'widgets'] })
}
const apps = program.command('apps').description('Apps: list (the default), show.').action(listApps)
apps.command('list').description('List the apps you may use.').action(listApps)
apps.command('show <app>')
.description("An app's home: its sections of actions (processes), reports and data (tables, with record counts), and its widgets.")
.option('--no-counts', 'skip the record count of each table')
.option('--widgets', 'also load and show every widget of the app (once each, in declared order, sized in twelfths): a dashboard app as the web shows it')
.option('--ascii', 'with --widgets, ASCII bars instead of block characters')
.action(async (name: string, options: { counts: boolean; widgets?: boolean; ascii?: boolean }) => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
const home = appHome(metaData, name)
if (!home) throw new UsageError(`No app named ${name} (see \`qqq apps\`)`)
const tables = (metaData.tables ?? {}) as Record<string, Json>
const fetchImpl = authenticatedFetch(c.target)
const rows = await Promise.all(home.sections.flatMap((section) => section.items.map(async (item) => {
let count: unknown = ''
if (item.type === 'TABLE' && options.counts && ((tables[item.name]?.capabilities ?? []) as string[]).includes('TABLE_COUNT')) {
try {
count = ((await callOperation(c.server, 'countTable', { path: { tableName: item.name }, json: { filter: { criteria: [], booleanOperator: 'AND' } } }, fetchImpl)).data as Json).count ?? ''
} catch {
count = '?'
}
}
return { section: section.label, type: item.type.toLowerCase(), name: item.name, label: item.label, records: count }
})))
const widgets = home.widgets.map((w) => ({ section: 'Widgets', type: 'widget', name: w, label: String(((metaData.widgets ?? {}) as Record<string, Json>)[w]?.label ?? w), records: '' }))
if (!options.widgets) {
c.print({ data: { ...home, items: [...rows, ...widgets] }, rows: [...rows, ...widgets], columns: ['section', 'type', 'name', 'label', 'records'], footer: home.label })
return
}
const started = Date.now()
const loaded = await loadDashboard(c.server, fetchImpl, home.widgets, { widgets: (metaData.widgets ?? {}) as Record<string, Json> })
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: { ...home, items: rows, widgets: loaded.map((w) => ({ name: w.name, label: w.label, type: w.type, gridColumns: w.gridColumns, isCard: w.isCard, data: w.data, skipped: w.skipped, error: w.error })) } })
return
}
const width = process.stdout.columns ?? 100
const parts = [home.label, ...(rows.length ? [formatTable(rows, ['section', 'type', 'name', 'label', 'records'], ['Section', 'Type', 'Name', 'Label', 'Records'])] : [])]
for (const w of loaded) {
const body = blocksToText(w.blocks.filter((b, i) => !(i === 0 && b.kind === 'heading')), { width, ascii: options.ascii })
parts.push(`${w.isCard ? '== ' : ''}${w.label} (${w.gridColumns}/12)${w.isCard ? ' ==' : ''}\n${body}`)
}
process.stdout.write(parts.join('\n\n') + '\n')
c.note(`${loaded.length} widget${loaded.length === 1 ? '' : 's'} loaded once each in ${Date.now() - started} ms${loaded.some((w) => w.skipped) ? `; skipped: ${loaded.filter((w) => w.skipped).map((w) => `${w.name} (${w.skipped})`).join(', ')}` : ''}`)
})
program.command('home')
.description('The landing page: counts of what you may open, quick actions (processes in your apps, with the command to run each) and one card per app.')
.action(async () => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
const home = homePage(metaData)
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: home })
return
}
const counts = `${home.counts.apps} apps, ${home.counts.tables} tables, ${home.counts.processes} processes, ${home.counts.reports} reports, ${home.counts.widgets} widgets`
const actions = home.quickActions.length ? formatTable(home.quickActions.map((a) => ({ action: a.label, app: a.app, command: `qqq process run ${a.name}` })), ['action', 'app', 'command'], ['Quick action', 'App', 'Command']) : '(no quick actions)'
const cards = formatTable(home.apps.map((a) => ({ app: a.label, name: a.name, tables: a.tables || '', processes: a.processes || '', reports: a.reports || '', widgets: a.widgets || '', apps: a.apps || '' })), ['app', 'name', 'tables', 'processes', 'reports', 'widgets', 'apps'], ['App', 'Name', 'Tables', 'Processes', 'Reports', 'Widgets', 'Apps'])
process.stdout.write(`${String((metaData.branding as Json | undefined)?.appName ?? c.server)}\n${counts}\n\n${actions}\n\n${cards}\n`)
})
program.command('find <text...>')
.description('Find an app, table, process or report by label (the command palette): type, app, and the command that opens it.')
.option('-t, --type <type>', 'only apps, tables, processes or reports')
.action(async (words: string[], options: { type?: string }) => {
const c = ctx()
const types = options.type ? [({ app: 'APP', apps: 'APP', table: 'TABLE', tables: 'TABLE', process: 'PROCESS', processes: 'PROCESS', report: 'REPORT', reports: 'REPORT' } as Record<string, NavigationTarget['type']>)[options.type.toLowerCase()] ?? (() => { throw new UsageError(`--type: app, table, process or report`) })()] : undefined
const found = findNavigationTargets(await c.metaData() as unknown as Json, words.join(' '), types)
const command = (t: NavigationTarget) => t.type === 'APP' ? `qqq apps show ${t.name}` : t.type === 'TABLE' ? `qqq table query ${t.name}` : t.type === 'PROCESS' ? `qqq process run ${t.name}` : `qqq report run ${t.name}`
c.print({ data: found, rows: found.map((t) => ({ label: t.label, type: t.type.toLowerCase(), app: t.app, command: command(t) })), columns: ['label', 'type', 'app', 'command'], footer: countFooter(found.length, 'match', [], 'matches') })
})
program.command('search <text...>')
.description('Search records across tables (the global search of the web UI; quick search when it is installed): each hit with its table, key, label and the matched field. --json prints the whole response.')
.option('-t, --table <name>', 'only this table (repeat, or comma-separate, for several)', (v: string, all: string[] = []) => [...all, v])
.option('-l, --limit <n>', 'most results per table', '20')
.addHelpText('after', `\nQuick search syntax:\n${QUICK_SEARCH_SYNTAX.map((l) => ` ${l}`).join('\n')}\n For conditions on fields use qqq table query <table> -w ….\n`)
.action(async (words: string[], options: { table?: string[]; limit: string }) => {
const c = ctx()
const text = words.join(' ')
const tables = (options.table ?? []).flatMap((t) => t.split(',')).map((t) => t.trim()).filter(Boolean)
const result = await c.call('searchRecords', { json: { searchTerm: plainSearchText(text) || text, tableNames: tables.length ? tables : undefined, limitPerTable: positiveInt(options.limit, '--limit') } })
const data = result.data as Json
const grouped = groupSearchResults(data)
const results = grouped.groups.flatMap((g) => g.hits.map((h) => ({ table: g.table, key: h.key, label: h.label, match: h.snippet ?? '' })))
const notes = unsupportedSyntax(text)
const extra = [grouped.groups.length > 1 && `${grouped.groups.length} tables`, grouped.total > results.length && `${grouped.total} matches in all, --limit shows more per table`, ...notes, grouped.provider === 'NONE' && 'this server has no search provider']
c.print({ data, rows: results, columns: ['table', 'key', 'label', 'match'], footer: countFooter(results.length, 'result', extra) })
})
program.command('tail')
.description('Follow your jobs and notifications live (realtime stream, channel user:me).')
.option('-c, --channel <channels...>', 'more channels, e.g. table:person or record:person:42')
.action(async (options: { channel?: string[] }) => {
const c = ctx()
const t = c.target
const origin = t.server
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
const session = await sessionCookieHeaderFor(t)
const stream = openRealtimeStream({ baseUrl: origin, token: () => currentToken(t), clientChannel: CLIENT_CHANNEL, signal: controller.signal, ...(session ? { headers: { Cookie: session } } : {}) } as never)
try {
await follow(stream)
} catch (error) {
// Ctrl-C ends a follow as `tail -f` does: quietly, exit 130
if (!controller.signal.aborted) throw error
}
if (controller.signal.aborted) process.exitCode = 130
async function follow(messages: typeof stream): Promise<void> {
for await (const message of messages) {
const data = (message.data ?? {}) as Json
if (message.event === 'hello') {
const connectionId = String(data.connectionId ?? (data.data as Json | undefined)?.connectionId ?? '')
const add = ['user:me', ...(options.channel ?? [])]
await unwrap(c.client.POST('/qqq/v1/realtime/subscriptions', { body: { connectionId, add } }))
c.note(`Following ${add.join(', ')} on ${origin} (Ctrl-C to stop).`)
continue
}
if (c.format === 'json') {
console.log(JSON.stringify(message))
continue
}
const payload = (data.data ?? data) as Json
const time = new Date().toTimeString().slice(0, 8)
const detail = payload.label ?? payload.title ?? payload.message ?? payload.jobId ?? ''
const progress = payload.percent !== undefined && payload.percent !== null ? ` ${payload.percent}%` : ''
console.log(`${time} ${message.event}${data.channel ? ` [${data.channel}]` : ''} ${detail}${progress}${payload.status ? ` ${payload.status}` : ''}`.trim())
}
}
})
}
+265
View File
@@ -0,0 +1,265 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Printing (the printing extension, qbit-printing 2.x):
*
* qqq print <table> <id...> [--printer <name|id>] [--copies n] [--no-wait] print records
* qqq print list <table> [-w criteria] [--order f] [--columns a,b] [--page n] a table list
* qqq print text [file|-] --title t text (a capture)
* qqq print job list [--status s] [--limit n] recent jobs
* qqq print job status <jobId...> [--watch] job states
* qqq print job cancel <jobId...> cancel queued jobs
* qqq printer list [--printable] printers
* qqq printer use <printer> | forget | show the remembered printer
* qqq printer devices print agents, health
*
* The kernel's Printing module does the work; every command needs the extension (exit 5 with the
* installed list otherwise). The printer given with --printer, or chosen with `printer use`, is
* remembered per profile and used when --printer is left out.
*/
import type { Command } from 'commander'
import { readFileSync } from 'node:fs'
import { FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
import { EXIT, UsageError, type Context } from '../context.js'
import { ORDER_OPTION, countFooter, instantText, positiveInt, type Json } from './shared.js'
import { resolvedFilterFrom, type QueryOptions } from './table.js'
const JOB_COLUMNS = ['id', 'title', 'status', 'printer', 'copies', 'created', 'message']
const PRINTER_COLUMNS = ['default', 'name', 'device', 'status', 'approved', 'exposed', 'formats', 'id']
async function printingOf(c: Context): Promise<Printing> {
const printing = await c.printing()
;(await c.extensions()).require('printing')
return printing
}
function jobRows(jobs: PrintJob[], c: Context): Json[] {
return jobs.map((j) => ({ id: j.id, title: j.title, status: j.status, printer: j.printer ?? '', copies: j.copies ?? '', created: instantText(j.createdAt ?? '', c.dateTimeDisplay), message: j.message ?? '' }))
}
function printerRows(printers: Printer[], remembered: string | undefined): Json[] {
return printers.map((p) => ({ default: p.id === remembered ? '*' : '', name: p.name, device: p.device ?? '', status: p.status ?? '', approved: p.approved ? 'yes' : 'no', exposed: p.exposed ? 'yes' : 'no', formats: p.formats.join(' '), id: p.id }))
}
/** Tables the server offers Print on, from the metadata. */
async function printableTables(c: Context): Promise<string[]> {
const processes = ((await c.metaData() as unknown as Json).processes ?? {}) as Record<string, unknown>
return Object.keys(processes).filter((n) => n.startsWith(PRINT_PROCESS_PREFIX)).map((n) => n.slice(PRINT_PROCESS_PREFIX.length)).sort()
}
/** --printer, else the remembered one; checked against the printers that can take a record print. */
async function choosePrinter(c: Context, printing: Printing, wanted: string | undefined): Promise<Printer> {
const printers = await printing.printers()
const remembered = readRememberedPrinter(c.target.name)
let printer: Printer | undefined
if (wanted) printer = resolvePrinter(printers, wanted)
else if (remembered) {
printer = printers.find((p) => p.id === remembered.id)
if (!printer) throw new UsageError(`The remembered printer${remembered.name ? ` "${remembered.name}"` : ''} is gone; choose one with --printer (see \`qqq printer list\`).`)
} else {
const printable = printers.filter((p) => !whyNotPrintable(p))
if (printable.length === 1) printer = printable[0]
else throw new UsageError(printable.length ? `Choose a printer with --printer: ${printable.map((p) => p.name).join(', ')} (it is remembered for this profile).` : 'No printer can take a print: none is approved and exposed by a connected print agent (see `qqq printer list` and `qqq printer devices`).')
}
const why = whyNotPrintable(printer!)
if (why) throw new UsageError(`Printer "${printer!.name}" cannot print now: ${why}.`)
return printer!
}
/** Prints the job lines as their states change, then the final table; exit 1 when any failed. */
async function follow(c: Context, printing: Printing, ids: string[], timeoutSeconds: number): Promise<PrintJob[]> {
const human = c.format !== 'json' && c.format !== 'yaml'
const seen = new Map<string, string>()
const jobs = await printing.watch(ids, {
timeoutMs: timeoutSeconds * 1000,
onChange: (list) => {
if (!human) return
for (const j of list) {
if (seen.get(j.id) === j.status) continue
seen.set(j.id, j.status)
c.note(` ${j.id} ${j.status}${j.message && isFinalJobState(j.status) ? ` ${j.message}` : ''}`)
}
},
})
if (jobs.some((j) => (FAILED_JOB_STATES as readonly string[]).includes(j.status))) process.exitCode = EXIT.error
else if (!jobs.every((j) => isFinalJobState(j.status))) {
c.note(`Still waiting after ${timeoutSeconds} s (${jobStateSummary(jobs)}); is the print agent running? Follow with \`qqq print job status ${ids.join(' ')} --watch\`.`)
process.exitCode = EXIT.transient
}
return jobs
}
export function registerPrint(program: Command, ctx: () => Context): void {
const print = program.command('print').description('Print records and follow print jobs (printing extension).')
print.command('record <table> <ids...>', { isDefault: true })
.description('Print records of a table as record sheets (`qqq print person 1 2`); waits until each job has printed.')
.option('-P, --printer <name|id>', 'the printer (remembered for this profile); default: the remembered one')
.option('-c, --copies <n>', 'copies of each record (1-100)', '1')
.option('--no-wait', 'return once the jobs are queued')
.option('--timeout <seconds>', 'how long to wait for the jobs', '120')
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string }) => {
const c = ctx()
const printing = await printingOf(c)
const tables = await printableTables(c)
if (!tables.includes(table)) throw new UsageError(`Print is not enabled for table "${table}" on this server${tables.length ? ` (tables with Print: ${tables.join(', ')})` : ''}.`)
const copies = positiveInt(options.copies, '--copies')
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
c.note(`Printing ${ids.length} ${table} record${ids.length === 1 ? '' : 's'} on "${printer.name}"${copies > 1 ? `, ${copies} copies each` : ''} (${printProcessName(table)})`)
const jobIds = await printing.print({ table, ids, printer: printer.id, copies })
if (!jobIds.length) throw new Error(`Nothing was printed: no ${table} record ${ids.join(', ')} was found (or you may not see ${ids.length === 1 ? 'it' : 'them'}).`)
const jobs = options.wait ? await follow(c, printing, jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: jobIds })
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs), !options.wait && `follow: qqq print job status ${jobIds.join(' ')} --watch`]) })
})
print.command('list <table>')
.description(`Print a table list (printing 2.1): the whole result of the criteria in the column order given (up to ${MAX_LIST_ROWS.toLocaleString('en-US')} rows; more prints the first rows and says so), or one page with --page.`)
.option('-w, --where <criteria...>', 'criteria, ANDed (as table query)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria instead of AND')
.option(...ORDER_OPTION)
.option('-S, --search <text>', 'quick search, ANDed with the filter')
.option('-c, --columns <names>', 'comma-separated fields, in order (default: the visible fields)')
.option('--page <n>', 'print only this page (1-based), of --page-size rows')
.option('--page-size <n>', 'rows per page for --page', '20')
.option('--title <text>', 'the heading (default: the table label)')
.option('--orientation <o>', 'AUTO (landscape when the columns need it), PORTRAIT or LANDSCAPE', 'AUTO')
.option('--paper <size>', 'LETTER or A4', 'LETTER')
.option('-P, --printer <name|id>', 'the printer (remembered for this profile); default: the remembered one')
.option('--copies <n>', 'copies (1-100)', '1')
.option('--no-wait', 'return once the job is queued')
.option('--timeout <seconds>', 'how long to wait for the job', '300')
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string }) => {
const c = ctx()
const printing = await printingOf(c)
const meta = await c.tableMeta<Json>(table)
const fields = (meta.fields ?? {}) as Record<string, Json>
const columns = options.columns ? options.columns.split(',').map((s) => s.trim()).filter(Boolean) : Object.values(fields).filter((f) => !f.isHidden && f.type !== 'BLOB').map((f) => String(f.name))
for (const name of columns) if (!fields[name]) throw new UsageError(`${table} has no field ${name}`)
const orientation = options.orientation.toUpperCase()
if (!['AUTO', 'PORTRAIT', 'LANDSCAPE'].includes(orientation)) throw new UsageError('--orientation takes AUTO, PORTRAIT or LANDSCAPE')
const paper = options.paper.toUpperCase()
if (paper !== 'LETTER' && paper !== 'A4') throw new UsageError('--paper takes LETTER or A4')
const filter = await resolvedFilterFrom(c, { ...options, pageSize: '500' }, meta, columns)
const pageSize = positiveInt(options.pageSize, '--page-size')
const scope = options.page ? { kind: 'page' as const, skip: (positiveInt(options.page, '--page') - 1) * pageSize, limit: pageSize } : { kind: 'all' as const }
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
c.note(`Printing ${scope.kind === 'all' ? 'all rows of' : `page ${options.page} of`} ${meta.label ?? table}${described ? ` where ${described}` : ''} on "${printer.name}"`)
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') })
if (outcome.message) c.note(outcome.message)
if (!outcome.jobIds.length) throw new Error('The server queued no print job.')
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs, message: outcome.message ?? null }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
})
print.command('text [file]')
.description('Print text as a monospace PDF (printing 2.1): a file, or stdin with - or no file (a terminal capture, a log).')
.requiredOption('--title <text>', 'the heading')
.option('--subtitle <text>', 'a line under the heading')
.option('--landscape', 'landscape pages (wide text)')
.option('-P, --printer <name|id>', 'the printer (remembered for this profile); default: the remembered one')
.option('--copies <n>', 'copies (1-100)', '1')
.option('--no-wait', 'return once the job is queued')
.option('--timeout <seconds>', 'how long to wait for the job', '120')
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string }) => {
const c = ctx()
const printing = await printingOf(c)
const text = readFileSync(file === undefined || file === '-' ? 0 : file, 'utf8')
if (!text.trim()) throw new UsageError('Nothing to print: the text is empty.')
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' })
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
})
const job = print.command('job').description('Print jobs: list, status, cancel.')
job.command('list')
.description('Recent print jobs, newest first.')
.option('-s, --status <states>', 'only these states, comma separated (QUEUED, CLAIMED, RECEIVED, SUBMITTING, SPOOLED, SPOOLER_COMPLETED, FAILED, OUTCOME_UNKNOWN)')
.option('-l, --limit <n>', 'most jobs', '25')
.action(async (options: { status?: string; limit: string }) => {
const c = ctx()
const jobs = await (await printingOf(c)).jobs({ status: options.status?.split(',').map((s) => s.trim()).filter(Boolean), limit: positiveInt(options.limit, '--limit') })
c.print({ data: jobs, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobs.length ? jobStateSummary(jobs) : undefined]) })
})
job.command('status <jobIds...>')
.description('The state of print jobs; --watch follows them until each has printed or failed.')
.option('-w, --watch', 'follow until every job is final')
.option('--timeout <seconds>', 'with --watch: how long to follow', '120')
.action(async (jobIds: string[], options: { watch?: boolean; timeout: string }) => {
const c = ctx()
const printing = await printingOf(c)
const jobs = options.watch ? await follow(c, printing, jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: jobIds })
const missing = jobIds.filter((id) => !jobs.some((j) => j.id === id))
if (!jobs.length) throw new Error(`No print job ${missing.join(', ')} (or it is not visible to you).`)
c.print({ data: jobs, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs), missing.length && `not found: ${missing.join(', ')}`]) })
})
job.command('cancel <jobIds...>')
.description('Cancel print jobs still queued in QQQ (a job a print agent already took is handled at the agent).')
.action(async (jobIds: string[]) => {
const c = ctx()
const printing = await printingOf(c)
if (!(await c.confirm(`Cancel ${jobIds.length} print job${jobIds.length === 1 ? '' : 's'}?`))) return
await printing.cancel(jobIds)
const jobs = await printing.jobs({ ids: jobIds })
c.print({ data: jobs, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
})
const printer = program.command('printer').description('Printers and print agents (printing extension); the printer `qqq print` uses.')
printer.command('list')
.description('Printers you can see: approved, exposed by their agent, formats. * marks the remembered one.')
.option('--printable', 'only printers that can take a record print now')
.action(async (options: { printable?: boolean }) => {
const c = ctx()
const printers = await (await printingOf(c)).printers({ printableOnly: options.printable })
const remembered = readRememberedPrinter(c.target.name)
c.print({ data: { printers, remembered: remembered ?? null }, rows: printerRows(printers, remembered?.id), columns: PRINTER_COLUMNS, footer: countFooter(printers.length, 'printer', [`${printers.filter((p) => !whyNotPrintable(p)).length} can print`, remembered ? `remembered: ${remembered.name ?? remembered.id}` : 'none remembered (qqq printer use <printer>)']) })
})
printer.command('use <printer>')
.description('Remember a printer for this profile (name, unique name prefix or id).')
.action(async (wanted: string) => {
const c = ctx()
const chosen = resolvePrinter(await (await printingOf(c)).printers(), wanted)
writeRememberedPrinter(c.target.name, { id: chosen.id, name: chosen.name })
const why = whyNotPrintable(chosen)
c.note(`Printer for profile ${c.target.name}: "${chosen.name}"${why ? ` (it cannot print now: ${why})` : ''}.`)
})
printer.command('show')
.description('The printer remembered for this profile.')
.action(async () => {
const c = ctx()
const remembered = readRememberedPrinter(c.target.name)
c.print({ data: remembered ?? null, rows: [{ profile: c.target.name, printer: remembered?.name ?? '', id: remembered?.id ?? '' }], columns: ['profile', 'printer', 'id'] })
})
printer.command('forget')
.description('Forget the printer remembered for this profile.')
.action(async () => {
const c = ctx()
writeRememberedPrinter(c.target.name, undefined)
c.note(`No printer remembered for profile ${c.target.name}.`)
})
printer.command('devices')
.description('Print agents: presence and health (ONLINE, OFFLINE, PAUSED, DISCONNECTED, REVOKED), version, last seen.')
.action(async () => {
const c = ctx()
const devices = await (await printingOf(c)).devices()
const rows = devices.map((d) => ({ name: d.name, status: d.status ?? '', agent: d.agentVersion ?? '', facility: d.facility ?? '', location: d.location ?? '', lastSeen: instantText(d.lastSeenAt ?? '', c.dateTimeDisplay), id: d.id }))
c.print({ data: devices, rows, columns: ['name', 'status', 'agent', 'facility', 'location', 'lastSeen', 'id'], footer: countFooter(devices.length, 'device', [`${devices.filter((d) => d.status === 'ONLINE').length} online`]) })
})
}
+269
View File
@@ -0,0 +1,269 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { mkdirSync } from 'node:fs'
import { basename, join } from 'node:path'
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { BACK, adHocControls, deniedProcessReason, parseFilter, displayDateTime, formatValue, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpToText, isMissingValue, processHelp, processHelpText, runProcess, safeFileName, saveDownload, screenToText, summaryLines, variantParam, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { ask, promptForm } from '../prompt.js'
import { authenticatedFetch } from '../session.js'
import { downloadDir, intOption, parsePairs, positiveInt, readFileArg, readJson, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
async function processMeta(c: Context, name: string): Promise<Json> {
return await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName: name } } })) as Json
}
export function registerProcess(program: Command, ctx: () => Context): void {
const proc = program.command('process').description('Processes: list, describe, run.')
proc.command('list')
.description('List the processes you may run (hidden ones with --all).')
.option('--all', 'include hidden processes (bulk edit/delete and the like)')
.option('-t, --table <name>', 'only processes of this table')
.action(async (options: { all?: boolean; table?: string }) => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
const rows = Object.values((metaData.processes ?? {}) as Record<string, Json>)
.filter((p) => (options.all || !p.isHidden) && (!options.table || p.tableName === options.table))
.map((p) => ({ name: p.name, label: p.label, table: p.tableName ?? '', hidden: p.isHidden ? 'yes' : '', note: deniedProcessReason(p) ?? '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
c.print({ data: rows, columns: ['name', 'label', 'table', 'hidden', ...(rows.some((r) => r.note) ? ['note'] : [])] })
})
proc.command('describe <process>')
.description("A process's steps and the fields each step asks for (what --values can set).")
.action(async (name: string) => {
const c = ctx()
const meta = await processMeta(c, name)
const rows: Json[] = []
for (const step of (meta.frontendSteps ?? []) as Json[]) {
const components = ((step.components ?? []) as Json[]).map((x) => x.type).join(', ')
const formFields = (step.formFields ?? []) as Json[]
if (!formFields.length) rows.push({ step: step.name, label: step.label, components, field: '', type: '', required: '' })
for (const f of formFields) rows.push({ step: step.name, label: step.label, components, field: f.name, type: f.type, required: f.isRequired ? 'yes' : '' })
}
// the same help `qqq help show process <p>` prints
const help = processHelp(meta)
c.print({ data: meta, rows, columns: ['step', 'label', 'components', 'field', 'type', 'required'], footer: `${meta.label} (${meta.name})${meta.tableName ? ` on table ${meta.tableName}` : ''}; records: ${meta.minInputRecords ?? 0}..${meta.maxInputRecords ?? 'any'}${help.length ? `\n\nHelp:\n${helpToText(help)}` : ''}` })
})
proc.command('run <process>')
.description('Run a process to completion: init, then each step with the given values, polling asynchronous jobs. Each screen is shown (help, values, records, review, results); on a terminal, missing values are asked for (-i asks for every field). Ctrl-C cancels the run on the server.')
.option('-v, --values <name=value...>', 'input values for form fields (any step); possible-value fields take a label or an id')
.option('--values-json <json|file|->', 'input values as a JSON object')
.option('-r, --records <ids>', 'comma-separated primary keys of the process table to run on')
.option('--filter-json <json|file|->', 'a QueryFilter selecting the records to run on')
.option('-F, --file <field=path...>', 'a file for a BLOB input field (uploads, bulk load)')
.option('-i, --interactive', 'prompt for every step field, not only missing required ones')
.option('--no-prompt', 'never prompt; fail when a required value is missing (for scripts)')
.option('--timeout <ms>', 'how long each step may run before the server backgrounds it', '0')
.option('--skip-validation', 'on a validation review step, skip full validation')
.option('--validate', 'on a validation review step, run full validation first')
.option('--out-dir <dir>', 'where files a step offers for download are saved (default: the download directory, else the current one)')
.option('--record-limit <n>', 'most records a record list shows', '100')
.option('--quiet', 'no progress or intermediate screens on stderr')
.option(...VARIANT_OPTION)
.action(async (name: string, options: RunOptions) => {
const c = ctx()
const meta = await processMeta(c, name) as unknown as ProcessMeta
// a DISABLED process is listed with hasPermission false: refuse with the reason (the server would too)
const denied = deniedProcessReason(meta as unknown as Json)
if (denied) throw new UiApiError(403, { code: 'PERMISSION_DENIED', message: denied })
// a process on a variant table runs with the variant on init and every step (as the web UI does)
const tableVariant = meta.tableName && (options.variant !== undefined || ((await c.metaData() as unknown as Json).tables as Record<string, Json> | undefined)?.[meta.tableName])
? variantParam(await chooseVariant(c, await c.tableMeta<Json>(meta.tableName), options.variant))
: undefined
if (options.variant !== undefined && !meta.tableName) throw new UsageError(`${meta.label ?? name} has no table, so it takes no --variant`)
const given: Record<string, unknown> = { ...(options.valuesJson ? readJson(options.valuesJson, '--values-json') as Json : {}), ...parsePairs(options.values, '--values') }
if (options.validate && options.skipValidation) throw new UsageError('--validate and --skip-validation exclude each other.')
if (options.validate) given.doFullValidation = 'true'
if (options.skipValidation) given.doFullValidation = 'false'
const canPrompt = options.prompt !== false && Boolean(process.stdin.isTTY)
const fetchImpl = authenticatedFetch(c.target)
const records = options.records ? { ids: options.records.split(',').map((x) => x.trim()).filter(Boolean) } : options.filterJson ? { filter: readJson(options.filterJson, '--filter-json') } : undefined
try {
await checkInputRecords(meta, records, meta.tableName ? async (filter) => Number(((await c.call('countTable', { path: { tableName: String(meta.tableName) }, json: { filter } })).data as Json).count ?? 0) : undefined)
} catch (error) {
if (error instanceof ProcessError) throw new UsageError(error.message)
throw error
}
const fileFields = new Set(Object.keys(parsePairs(options.file, '--file')))
const width = process.stdout.columns ?? 100
const textOf = (screen: ScreenBlock[]) => screenToText(screen, { table: (rows, columns, headers) => formatTable(rows, columns, headers), widget: (blocks) => blocksToText(blocks, { width }) })
const saved: string[] = []
const saveDownloads = async (screen: ScreenBlock[]) => {
for (const block of screen) {
if (block.kind !== 'download') continue
const file = await downloadProcessFile(c.server, fetchImpl, block)
const dir = options.outDir ?? downloadDir()
mkdirSync(dir, { recursive: true })
// the name comes from the server: last segment only, and never overwrite an existing file
const path = saveDownload(join(dir, safeFileName(block.fileName)), file.bytes, { explicit: false })
saved.push(path)
c.note(`saved ${path} (${file.bytes.length} bytes${file.contentType ? `, ${file.contentType}` : ''})`)
}
}
// Ctrl-C: the first cancels the run on the server, a second leaves at once
const abort = new AbortController()
let declined = false
const onInterrupt = () => {
if (abort.signal.aborted) process.exit(130)
c.note('\ncancelling (Ctrl-C again to leave at once)...')
abort.abort()
}
process.on('SIGINT', onInterrupt)
if (!options.quiet) for (const text of processHelpText(meta as never)) c.note(text)
let outcome: RunOutcome
try {
outcome = await runProcess({
baseUrl: c.server,
fetch: fetchImpl,
meta,
values: given,
records,
files: Object.fromEntries(Object.entries(parsePairs(options.file, '--file')).map(([field, path]) => [field, { name: basename(path), bytes: readFileArg(path, `--file ${field}`) }])),
stepTimeoutMillis: intOption(options.timeout, '--timeout'),
tableVariant,
always: Boolean(options.interactive),
signal: abort.signal,
screen: { recordLimit: positiveInt(options.recordLimit, '--record-limit'), format: (field, value, shown) => (field.type === 'DATE_TIME' ? displayDateTime(value, shown, c.dateTimeDisplay) : formatValue(field, value)) },
onProgress: (e) => {
if (options.quiet) return
if (e.phase === 'init') c.note(`${meta.label ?? name}: starting`)
else if (e.phase === 'running') c.note(` running${e.current !== undefined ? ` ${e.current}/${e.total ?? '?'}` : ''}${e.message ? ` ${e.message}` : ''}`)
else if (e.phase === 'back') c.note(` back to ${e.step}`)
else if (e.phase === 'cancel') c.note(' asking the server to cancel the run')
},
present: async ({ step, screen, isLast }) => {
await saveDownloads(screen)
if (isLast || options.quiet) return
const text = textOf(screen)
if (text) c.note(`\n== ${step.label ?? step.name} ==\n${text}`)
},
provide: async ({ step, fields, values, missing, screen, backStep }) => {
const out: Record<string, unknown> = {}
const review = screen.find((b) => b.kind === 'review')
if (review && review.kind === 'review') {
if (given.doFullValidation === undefined && values.validationSummary === undefined && review.supportsFullValidation && canPrompt) {
const answer = await ask('Validate fully before running, or skip validation? [v/S] ', { signal: abort.signal })
out.doFullValidation = /^v/i.test(answer) ? 'true' : 'false'
given.doFullValidation = out.doFullValidation
} else if (given.doFullValidation === undefined && values.validationSummary === undefined && review.supportsFullValidation && !canPrompt) {
throw new UsageError(`Step "${step.label ?? step.name}" is a validation review: pass --validate or --skip-validation (or run on a terminal to be asked).`)
} else if (values.validationSummary !== undefined && canPrompt && !c.options.yes) {
const answer = await ask('Submit? [Y/n] ', { signal: abort.signal })
if (/^n/i.test(answer)) {
declined = true
abort.abort()
throw new Error('not submitted')
}
}
}
// a field drawn by a filterAndColumnsSetup widget (WIDGET adornment) takes a filter: the grammar becomes its QueryFilter JSON
const widgetMeta = ((await c.metaData() as unknown as Json).widgets ?? {}) as Record<string, Json>
for (const f of fields) {
const adornment = (f.adornments ?? []).find((a) => a.type === 'WIDGET') as { values?: Record<string, unknown> } | undefined
const widgetType = adornment ? widgetMeta[String(adornment.values?.widgetName ?? '')]?.type : undefined
const typed = given[f.name]
if (widgetType === 'filterAndColumnsSetup' && typeof typed === 'string' && typed.trim() && !typed.trim().startsWith('{')) {
try {
given[f.name] = JSON.stringify(parseFilter(typed))
} catch (error) {
throw new UsageError(`--values ${f.name}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
// every step's fields go through a form session: labels resolve, contingent fields clear and disable, values validate
const inputs = fields.filter((f) => !(f.type === 'BLOB' && fileFields.has(f.name)))
if (inputs.length) {
const session = new FormSession({ baseUrl: c.server, fetch: fetchImpl, scope: { process: name }, fields: inputs, initialValues: values })
for (const n of session.promptOrder(fields.map((f) => f.name).filter((n) => given[n] !== undefined))) {
try {
await session.set(n, given[n])
} catch (error) {
if (error instanceof FormInputError) throw new UsageError(error.message)
throw error
}
}
if (!canPrompt) {
const still = missing.filter((f) => isMissingValue(session.values[f.name]))
if (still.length) throw new UsageError(`Step "${step.label ?? step.name}" needs ${still.map((f) => `--values ${f.name}=…`).join(' ')} (qqq process describe ${name} lists every field; run on a terminal to be asked)`)
} else {
// ask for whatever the step still lacks (the server decides what is truly required); -i asks for everything
await promptForm(session, { skipSet: !options.interactive, signal: abort.signal })
}
const problems = session.validate(fields.map((f) => f.name).filter((n) => !fileFields.has(n)))
if (Object.keys(problems).length) throw new UiApiError(400, { code: 'BAD_REQUEST', message: `Step "${step.label ?? step.name}" has invalid values`, fieldErrors: Object.entries(problems).map(([field, message]) => ({ field, message: `${session.fields[field]?.label ?? field} ${message}` })) })
for (const f of fields) if (session.values[f.name] !== undefined) out[f.name] = session.values[f.name]
}
// an ad hoc composite widget's buttons: an actionCode submits the step with it
const buttons = adHocControls(step, values).buttons.filter((b) => b.actionCode)
if (buttons.length && given.actionCode === undefined) {
if (canPrompt) {
buttons.forEach((b, i) => c.note(` ${i + 1}. ${b.label}`))
const answer = await ask(`Choose a button (1-${buttons.length})${inputs.length ? ', or Enter to submit the values' : ''}: `, { signal: abort.signal })
const chosen = buttons[Number(answer) - 1] ?? buttons.find((b) => b.label.toLowerCase() === answer.toLowerCase() || b.actionCode === answer)
if (chosen) out.actionCode = chosen.actionCode
else if (answer || !inputs.length) throw new UsageError(`No button "${answer}" (${buttons.map((b) => b.label).join(', ')})`)
} else if (!inputs.length) {
throw new UsageError(`Step "${step.label ?? step.name}" offers buttons: pass --values actionCode=<code> (${buttons.map((b) => `${b.actionCode} = ${b.label}`).join(', ')})`)
}
} else if (given.actionCode !== undefined) out.actionCode = given.actionCode
if (backStep && canPrompt) {
const backLabel = outcomeStepLabel(meta, backStep)
const answer = await ask(`Enter to continue, b to go back to ${backLabel}: `, { signal: abort.signal })
if (/^b/i.test(answer)) return BACK
}
return out
},
})
} catch (error) {
if (error instanceof ProcessCancelledError && declined) {
// answering no is a choice, as "Not deleted." is for table delete: exit 0
c.note(`Not submitted; ${meta.label ?? name} ${error.message}${error.processUUID ? ` (process ${error.processUUID})` : ''}`)
return
}
if (error instanceof ProcessCancelledError) {
c.note(`${meta.label ?? name}: ${error.message}${error.processUUID ? ` (process ${error.processUUID})` : ''}`)
process.exitCode = 130
return
}
throw error
} finally {
process.off('SIGINT', onInterrupt)
}
printOutcome(c, outcome, textOf, saved)
})
}
interface RunOptions { values?: string[]; valuesJson?: string; records?: string; filterJson?: string; file?: string[]; interactive?: boolean; prompt: boolean; timeout: string; skipValidation?: boolean; validate?: boolean; outDir?: string; recordLimit: string; quiet?: boolean; variant?: string }
function outcomeStepLabel(meta: ProcessMeta, stepName: string): string {
return meta.frontendSteps?.find((s) => s.name === stepName)?.label ?? stepName
}
/** Prints the last screen: as text on table/text output, else the response with summary or value rows. */
export function printOutcome(c: Context, outcome: RunOutcome, textOf: (screen: ScreenBlock[]) => string, saved: string[]): void {
const out = outcome.values
if ((c.format === 'table' || c.format === 'text') && !c.options.query && outcome.lastScreen?.length) {
const text = textOf(outcome.lastScreen)
process.stdout.write(`${text || 'Done.'}${saved.length ? `\n${saved.map((p) => `saved: ${p}`).join('\n')}` : ''}\n`)
return
}
const summary = (out.processResults ?? out.recordListSummary ?? []) as Json[]
const viewFields = (outcome.lastStep?.viewFields ?? []) as FieldMeta[]
if (Array.isArray(summary) && summary.length) {
const rows = summaryLines(summary).map((line) => ({ status: line.status, count: line.count ?? '', message: line.message }))
c.print({ data: outcome.response, rows, columns: ['status', 'count', 'message'] })
} else {
const names = viewFields.length ? viewFields.map((f) => f.name) : Object.keys(out).filter((k) => out[k] !== null && typeof out[k] !== 'object').slice(0, 30)
const rows = names.map((k) => ({ value: viewFields.find((f) => f.name === k)?.label ?? k, result: String(out[k] ?? '') }))
c.print({ data: outcome.response, rows, columns: ['value', 'result'], footer: rows.length ? '' : 'Done.' })
}
}
+226
View File
@@ -0,0 +1,226 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The record view and related records: `table get` (sections, adornments, help, associations),
* `table children` (a page of one child list, with its count) and `table download` (a stored
* file). The models are the kernel's (records/associations, records/record-view).
*/
import type { Command } from 'commander'
import { exportFileName,
associationGroups, associationLabel, chipAnsi, associationsOf, childListFields, fetchChildren, fileDownload, safeFileName, findAssociation, helpToText, recordView, relationshipValues, tableHelp, VIEW_SCREEN_HELP_ROLES,
defaultJoinColumns, withJoinFields, fieldLabel, variantBody, variantParam, type ExposedJoin, type RecordLike, type TableMetaLike, type WidgetMetaLike,
} from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import { countFooter, intOption, useColor, valueText, writeDownload, requireTable, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
/** Reads one record (`{ record }` or a bare record). */
export async function getRecord(c: Context, table: string, primaryKey: string, associations: boolean, query: Record<string, string | undefined> = {}): Promise<Json> {
const result = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey }, query: { includeAssociations: associations ? true : undefined, ...query } as never } })) as Json
return (result.record ?? result) as Json
}
/** Table metadata for the record's table and every table its associations reach (missing ones stay undefined: no access). */
async function associationTables(c: Context, root: TableMetaLike): Promise<Record<string, TableMetaLike | undefined>> {
const tables: Record<string, TableMetaLike | undefined> = { [root.name]: root }
const known = new Set(Object.keys((await c.metaData()).tables ?? {}))
const queue = [root]
while (queue.length) {
const table = queue.shift()!
for (const association of associationsOf(table)) {
const name = association.associatedTableName
if (name in tables) continue
tables[name] = undefined
if (!known.has(name)) continue
try {
const meta = await c.tableMeta<TableMetaLike>(name)
tables[name] = meta
queue.push(meta)
} catch (error) {
// no access to the child table (403, or 404 for a table the user may not see): shown as
// unavailable; anything else (the server down, a 500) is a real failure
if (!(error instanceof UiApiError && (error.status === 403 || error.status === 404))) throw error
}
}
}
return tables
}
/** Prints a record: its sections, then links, help and child lists as asked. */
export async function showRecord(c: Context, table: string, primaryKey: string, options: { associations?: boolean; links?: boolean; reveal?: boolean; helpContent?: boolean; query?: Record<string, string | undefined>; joined?: ExposedJoin[] }): Promise<void> {
const meta = await c.tableMeta<TableMetaLike>(table)
const metaData = await c.metaData()
const record = await getRecord(c, table, primaryKey, Boolean(options.associations), options.query) as unknown as RecordLike
const widgets = (metaData.widgets ?? {}) as Record<string, WidgetMetaLike>
const tableNames = Object.keys(metaData.tables ?? {})
const sections = recordView(meta, record, { reveal: options.reveal, tableNames, widgets, helpContent: options.helpContent, tableName: table, primaryKey, empty: c.format === 'table' ? '--' : '', dateTime: c.dateTimeDisplay })
const rows: Json[] = []
for (const section of sections) {
if (options.helpContent && section.help) rows.push({ section: section.label, field: '(help)', value: section.help })
for (const { field, value, help } of section.fields) {
rows.push({ section: section.label, field: field.label, value: value.chip && useColor(c.format) ? chipAnsi(value.text, value.chip.color) : value.text })
if (help) rows.push({ section: section.label, field: '', value: `help: ${help}` })
}
}
// fields of joined tables (--join), one section per join
for (const join of options.joined ?? []) {
for (const name of defaultJoinColumns(join, 1000)) rows.push({ section: join.label, field: fieldLabel(meta as unknown as Json, options.joined!, name), value: valueText(record as unknown as Json, name, withJoinFields(meta as unknown as Json, options.joined!), c.dateTimeDisplay, options.reveal) })
}
const links = sections.flatMap((s) => s.fields).filter((x) => x.value.link && 'tableName' in x.value.link)
const structured = c.format === 'json' || c.format === 'yaml'
const footer = [String(record.recordLabel ?? `${meta.label} ${primaryKey}`)]
if (links.length && !options.links) footer.push(`links: ${links.map((x) => x.field.name).join(', ')} (--links lists them, --follow <field> opens one)`)
const masked = sections.flatMap((s) => s.fields).filter((x) => x.value.masked)
if (masked.length) footer.push(`masked: ${masked.map((x) => x.field.name).join(', ')} (--reveal shows them)`)
if (!options.associations && associationsOf(meta).length) footer.push(`associations: ${associationsOf(meta).map((a) => a.name).join(', ')} (--associations lists the child records)`)
c.print({ data: record, rows, columns: ['section', 'field', 'value'], headers: ['Section', 'Field', 'Value'], footer: footer.join('\n') })
if (structured) return
if (options.helpContent) {
// the table's own help, as `qqq help show table <t>` prints it (sections and fields are inline above)
const help = tableHelp(meta as unknown as Json, { role: VIEW_SCREEN_HELP_ROLES }).filter((e) => e.where === 'table' || e.where.startsWith('table ('))
if (help.length) process.stdout.write(`\n${helpToText(help)}\n`)
}
if (options.links && links.length) {
c.print({
data: links,
rows: links.map(({ field, value }) => {
const link = value.link as { tableName: string; primaryKey: string }
return { field: field.name, label: field.label, record: String(record.displayValues?.[field.name] ?? record.values[field.name] ?? ''), table: link.tableName, key: link.primaryKey, open: `qqq table get ${table} ${primaryKey} --follow ${field.name}` }
}),
columns: ['field', 'label', 'record', 'table', 'key', 'open'],
headers: ['Field', 'Label', 'Record', 'Table', 'Key', 'Open with'],
})
}
if (options.associations) {
const tables = await associationTables(c, meta)
for (const group of associationGroups(record, meta, tables, { widgets })) {
const indent = ' '.repeat(group.depth - 1)
const title = group.depth === 1 ? `${group.title} [${group.association.name}]` : group.title
if (group.unavailable) {
process.stdout.write(`\n${indent}${title}: unavailable (${group.unavailable})\n`)
continue
}
// nested lists only when they have records; the record's own lists always (an empty one says none)
if (group.depth > 1 && !group.records.length) continue
const childTable = tables[group.table]!
const parentTable = tables[group.parentTable]!
const relationship = relationshipValues(group.association, parentTable, group.parent, childTable)
const binding = 'error' in relationship ? [] : Object.keys(relationship.values)
const fields = childListFields(childTable, binding)
const childRows = group.records.map((child) => Object.fromEntries(fields.map((f) => [f, valueText(child as unknown as Json, f, childTable as unknown as Json, c.dateTimeDisplay, options.reveal)])))
process.stdout.write(`\n${indent}${title} (${group.records.length})\n`)
if (childRows.length) c.print({ data: group.records, rows: childRows, columns: fields, headers: fields.map((f) => childTable.fields[f]?.label ?? f) })
else process.stdout.write(`${indent} none\n`)
}
}
}
export function registerRelated(program: Command, ctx: () => Context): void {
const table = program.commands.find((x) => x.name() === 'table')!
table.command('children <table> <primaryKey> [association]')
.description("Page through one child list of a record (an association: name or child table), with the total count; without an association, lists the record's associations and their counts.")
.option('-l, --limit <n>', 'children per page', '25')
.option('--skip <n>', 'children to skip', '0')
.option('--all', 'every child (pages of --limit)')
.option('--reveal', 'show REVEAL (masked) values')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, associationName: string | undefined, options: { limit: string; skip: string; all?: boolean; reveal?: boolean; variant?: string }) => {
const c = ctx()
const meta = await c.tableMeta<TableMetaLike>(name)
requireTable(meta as unknown as Json, 'read')
const all = associationsOf(meta)
if (!all.length) throw new UsageError(`${meta.label ?? name} has no associations (child lists).`)
const limit = intOption(options.limit, '--limit', 1)!
let skip = intOption(options.skip, '--skip')!
const variant = await chooseVariant(c, meta as unknown as Json, options.variant)
const parent = await getRecord(c, name, primaryKey, false, { tableVariant: variantParam(variant) }) as unknown as RecordLike
const widgets = ((await c.metaData()).widgets ?? {}) as Record<string, WidgetMetaLike>
const fetchImpl = authenticatedFetch(c.target)
// a child table on the same variant-backed backend is queried with the parent's variant
const childVariant = (child: TableMetaLike) => (variant && (child as unknown as Json).usesVariants === true ? variantBody(variant) : {})
if (!associationName) {
const rows: Json[] = []
for (const association of all) {
let childTable: TableMetaLike | undefined
let count: number | string = ''
try {
childTable = await c.tableMeta<TableMetaLike>(association.associatedTableName)
count = (await fetchChildren({ baseUrl: c.server, fetch: fetchImpl, parentTable: meta, parent, childTable, association, limit: 0, ...childVariant(childTable) })).count ?? ''
} catch (error) {
count = `unavailable: ${error instanceof Error ? error.message : String(error)}`
}
rows.push({ association: association.name, label: associationLabel(meta, association, { widgets, childTable }), table: association.associatedTableName, count })
}
c.print({ data: rows, columns: ['association', 'label', 'table', 'count'], footer: `qqq table children ${name} ${primaryKey} <association> pages one list` })
return
}
const association = findAssociation(meta, associationName)
if (!association) throw new UsageError(`No association "${associationName}" on ${meta.label ?? name}; associations: ${all.map((a) => `${a.name} (${a.associatedTableName})`).join(', ')}`)
const childTable = await c.tableMeta<TableMetaLike>(association.associatedTableName)
const records: RecordLike[] = []
let count: number | undefined
for (;;) {
const page = await fetchChildren({ baseUrl: c.server, fetch: fetchImpl, parentTable: meta, parent, childTable, association, limit, skip, count: count === undefined, ...childVariant(childTable) })
count ??= page.count
records.push(...page.records)
if (!options.all || page.records.length < limit) break
skip += limit
}
const relationship = relationshipValues(association, meta, parent, childTable)
const fields = childListFields(childTable, 'error' in relationship ? [] : Object.keys(relationship.values), 8)
const first = Number(options.skip)
const label = associationLabel(meta, association, { widgets, childTable })
c.print({
data: { association: association.name, table: childTable.name, count, skip: first, records },
rows: records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r as unknown as Json, f, childTable as unknown as Json, c.dateTimeDisplay, options.reveal)]))),
columns: fields,
headers: fields.map((f) => childTable.fields[f]?.label ?? f),
footer: countFooter(records.length, 'record', [
`${label} of ${parent.recordLabel ?? `${meta.label} ${primaryKey}`}`,
records.length ? `${first + 1}-${first + records.length} of ${count ?? '?'}` : `of ${count ?? '?'}`,
!options.all && count !== undefined && first + records.length < count && `next: --skip ${first + records.length}`,
]),
})
})
table.command('download <table> <primaryKey> <field>')
.description('Save a file stored in a record field (FILE_DOWNLOAD through GET /table/{t}/{pk}/{field}/{file}, or a BLOB value).')
.option('-o, --out <file>', 'where to write, replacing an existing file (default: the file name in the download directory, the date and time go before the extension when taken; - for stdout)')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, fieldName: string, options: { out?: string; variant?: string }) => {
const c = ctx()
const meta = await c.tableMeta<TableMetaLike>(name)
requireTable(meta as unknown as Json, 'read')
const field = meta.fields[fieldName]
if (!field) throw new UsageError(`No field "${fieldName}" in ${name}`)
const variant = await chooseVariant(c, meta as unknown as Json, options.variant)
const record = await getRecord(c, name, primaryKey, false, { tableVariant: variantParam(variant) }) as unknown as RecordLike
const file = fileDownload(field, record)
let bytes: Uint8Array
let fileName: string
if (file?.route) {
const result = await c.call('downloadRecordFieldFile', { path: file.route })
bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
fileName = safeFileName(file.fileName)
} else if (file?.url) {
c.print({ data: file, rows: [{ file: file.fileName, url: file.url }], columns: ['file', 'url'], footer: 'The file is outside the QQQ server; download it from the URL.' })
return
} else {
const raw = record.values[fieldName]
if (raw === null || raw === undefined || raw === '') throw new UsageError(`${meta.label} ${primaryKey} has no file in ${field.label}.`)
if (typeof raw !== 'string' || field.type !== 'BLOB') throw new UsageError(`${field.label} is not a file field (no FILE_DOWNLOAD adornment, not a BLOB).`)
bytes = new Uint8Array(Buffer.from(raw, 'base64'))
fileName = exportFileName(`${name}-${primaryKey}-${fieldName}`, '')
}
const written = writeDownload(options.out, fileName, bytes)
if (written) c.note(`wrote ${written} (${bytes.length} bytes)`)
})
}
+241
View File
@@ -0,0 +1,241 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Saved reports (`qqq saved-report`) and sharing (`qqq share`): show a saved report's filter,
* columns and pivot as the setup widgets do, change them, preview its rows, render it through
* renderSavedReport, schedule it, and share any shareable record (saved reports, saved views).
*/
import type { Command } from 'commander'
import {
RENDER_SAVED_REPORT_PROCESS, ReportError, SAVED_REPORT_TABLE, SCHEDULED_REPORT_TABLE, SHARE_SCOPES, SharingError, addShare, blocksToText, buildFilter, buildPivot, changeShare,
filterAndColumnsBlocks, filterDateTimesToUtc, findSavedReport, listReportSchedules, listSavedReports, listShares, pivotTableBlocks, printFilter, removeShare,
reportFormat, savedReportFilterValues, shareScope, trimChar, type FieldMeta, type QueryFilter, type SavedReport,
} from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import { currentUserId } from './saved-view.js'
import { ORDER_OPTION, countFooter, deprecatedOrderOption, intOption, orderFrom, valueText, type Json } from './shared.js'
/** A share scope's label (Read-Only, Read and Edit). */
function scopeLabel(id: string): string {
return SHARE_SCOPES.find((s) => s.id === id)?.label ?? id
}
async function guard<T>(f: () => Promise<T>): Promise<T> {
try {
return await f()
} catch (error) {
if (error instanceof ReportError || error instanceof SharingError) throw new UsageError(error.message)
throw error
}
}
async function savedReports(c: Context): Promise<SavedReport[]> {
const tables = ((await c.metaData() as unknown as Json).tables ?? {}) as Record<string, Json>
if (!tables[SAVED_REPORT_TABLE]) throw new UsageError('This server has no saved reports (the savedReport table).')
return listSavedReports(c.server, authenticatedFetch(c.target))
}
export function registerReports(program: Command, ctx: () => Context): void {
const sr = program.command('saved-report').description('Saved reports: list, show, preview, run, set (filter, sort, columns), pivot, schedules, schedule. Share one with `qqq share … savedReport <id>`.')
sr.command('list')
.description('Your saved reports and those shared with you.')
.action(async () => {
const c = ctx()
const me = await currentUserId(c)
const reports = await savedReports(c)
const rows = reports.map((r) => ({ id: r.id, label: r.label, table: r.tableName, owner: me && r.userId === me ? 'you' : r.userId ? `shared by ${r.userId}` : '', pivot: r.pivot ? 'yes' : '', filter: r.filter ? printFilter(r.filter) : '' }))
c.print({ data: reports.map((r) => r.raw), rows, columns: ['id', 'label', 'table', 'owner', 'pivot', 'filter'], footer: countFooter(rows.length, 'saved report') })
})
sr.command('show <report>')
.description("A saved report as its setup widgets show it: filter (match all/any), sort, visible columns, pivot table (rows, columns, values).")
.action(async (name: string) => {
const c = ctx()
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const meta = await c.tableMeta<Json>(report.tableName).catch(() => ({} as Json))
const label = (f: string) => String(((meta.fields ?? {}) as Record<string, Json>)[f]?.label ?? f)
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: report })
return
}
const values = ((report.raw.values ?? report.raw) as Json)
process.stdout.write([`${report.label} (${report.id}) on ${String(meta.label ?? report.tableName)}`, blocksToText(filterAndColumnsBlocks(values, {}, label)), 'Pivot table:', blocksToText(pivotTableBlocks(values, label))].join('\n') + '\n')
})
sr.command('preview <report>')
.description("The report's rows as its filter, sort and columns select them (paged by the server: --limit, --skip, --order replaces the sort). --api-path/--api-version preview through an application API, as API-versioned report setups do.")
.option('-l, --limit <n>', 'rows', '25')
.option('--skip <n>', 'rows to skip')
.option('--order <field...>', 'sort instead of the report\'s; prefix - for descending')
.addOption(deprecatedOrderOption())
.option('--api-path <path>', 'an application API path (e.g. /api/)')
.option('--api-version <version>', 'with --api-path, the API version')
.action(async (name: string, options: { limit: string; skip?: string; order?: string[]; o?: string[]; apiPath?: string; apiVersion?: string }) => {
const c = ctx()
options.order = orderFrom((text) => c.note(text), options)
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const meta = await c.tableMeta<Json>(report.tableName)
const filter: QueryFilter = { ...(report.filter ?? { criteria: [], booleanOperator: 'AND' }), skip: intOption(options.skip, '--skip') ?? 0, limit: intOption(options.limit, '--limit', 1)! }
if (options.order?.length) filter.orderBys = options.order.map((o) => ({ fieldName: o.replace(/^-/, ''), isAscending: !o.startsWith('-') }))
const columns = (report.columns ?? []).filter((col) => col.isVisible !== false && col.name !== '__check__').map((col) => col.name)
const fields = columns.length ? columns : Object.keys((meta.fields ?? {}) as Json).slice(0, 8)
let records: Json[]
let total: unknown
if (options.apiPath) {
if (!options.apiVersion) throw new UsageError('--api-path needs --api-version')
const base = `${c.server}/${trimChar(options.apiPath, '/')}/${options.apiVersion}/table/${report.tableName}`
const fetchImpl = authenticatedFetch(c.target)
const q = await (await fetchImpl(`${base}/query`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ filter }) })).json() as Json
const n = await (await fetchImpl(`${base}/count`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ filter: { ...filter, skip: undefined, limit: undefined } }) })).json() as Json
records = (q.records ?? []) as Json[]
total = n.count
} else {
records = ((await c.call('queryTable', { path: { tableName: report.tableName }, json: { filter } })).data as Json).records as Json[] ?? []
total = ((await c.call('countTable', { path: { tableName: report.tableName }, json: { filter: { criteria: filter.criteria, booleanOperator: filter.booleanOperator, subFilters: filter.subFilters } } })).data as Json).count
}
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, meta, c.dateTimeDisplay)])))
c.print({ data: records, rows, columns: fields, headers: fields.map((f) => String(((meta.fields ?? {}) as Record<string, Json>)[f]?.label ?? f)), footer: countFooter(rows.length, 'row', [`${String(total ?? '?')} in all`, `rows ${filter.skip! + 1}-${filter.skip! + rows.length}`]) })
})
sr.command('run <report>')
.description('Render a saved report through renderSavedReport and save the file (its input step asks for report variables; -v gives them).')
.option('--format <fmt>', 'csv, xlsx or json', 'csv')
.option('-v, --values <name=value...>', 'report variable values')
.option('--out-dir <dir>', 'where the file is saved')
.action(async (name: string, options: { format: string; values?: string[]; outDir?: string }) => {
const c = ctx()
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const format = await guard(async () => reportFormat(options.format))
const run = program.commands.find((x) => x.name() === 'process')!.commands.find((x) => x.name() === 'run')!
await run.parseAsync([RENDER_SAVED_REPORT_PROCESS, '-r', String(report.id), '-v', `reportFormat=${format}`, ...(options.values ?? []).flatMap((v) => ['-v', v]), ...(options.outDir ? ['--out-dir', options.outDir] : [])], { from: 'user' })
})
sr.command('set <report>')
.description("Change a saved report's filter and sort (--where/--group/--order, filter variables as ${variable.x}), visible columns (--columns), or name (--label), as the filter and columns setup does.")
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option(...ORDER_OPTION)
.addOption(deprecatedOrderOption())
.option('--clear-filter', 'no filter (every record)')
.option('--columns <names>', 'visible columns, in order (comma-separated)')
.option('--label <text>', 'a new name')
.action(async (name: string, options: { where?: string[]; group?: string[]; or?: boolean; order?: string[]; o?: string[]; clearFilter?: boolean; columns?: string; label?: string }) => {
const c = ctx()
options.order = orderFrom((text) => c.note(text), options)
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const meta = await c.tableMeta<Json>(report.tableName)
const known = Object.keys((meta.fields ?? {}) as Json)
const touchesFilter = options.where || options.group || options.order || options.clearFilter
const filter = options.clearFilter ? { criteria: [], booleanOperator: 'AND' } as QueryFilter
: touchesFilter ? filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: known }), (meta.fields ?? {}) as Record<string, FieldMeta>) : undefined
if (filter && !options.where && !options.group && !options.clearFilter && report.filter) {
filter.criteria = report.filter.criteria
filter.booleanOperator = report.filter.booleanOperator
if (report.filter.subFilters) filter.subFilters = report.filter.subFilters
}
const columns = options.columns?.split(',').map((f) => f.trim()).filter(Boolean)
for (const col of columns ?? []) if (!known.includes(col)) throw new UsageError(`${col}: not a field of ${String(meta.label ?? report.tableName)}`)
const body = { ...savedReportFilterValues(filter, columns), ...(options.label ? { label: options.label } : {}) }
if (!Object.keys(body).length) throw new UsageError('Nothing to change: --where/--group/--order/--clear-filter, --columns or --label.')
await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: SAVED_REPORT_TABLE, primaryKey: String(report.id) } }, body: body as never }))
c.print({ data: body, rows: Object.keys(body).map((k) => ({ changed: k, value: String(body[k as keyof typeof body]) })), columns: ['changed', 'value'], footer: `${report.label} (${report.id}) updated` })
})
sr.command('pivot <report>')
.description("Set (--rows, --columns, --values FUNCTION:field) or clear (--clear) a saved report's pivot table, as the pivot table setup does.")
.option('--rows <fields>', 'row fields, comma-separated')
.option('--columns <fields>', 'column fields, comma-separated')
.option('--values <specs>', 'values, comma-separated FUNCTION:field (SUM, COUNT, COUNT_NUMS, AVERAGE, MAX, MIN, PRODUCT, STD_DEV, STD_DEVP, VAR, VARP)')
.option('--clear', 'remove the pivot table')
.action(async (name: string, options: { rows?: string; columns?: string; values?: string; clear?: boolean }) => {
const c = ctx()
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const meta = await c.tableMeta<Json>(report.tableName)
const list = (s?: string) => s?.split(',').map((x) => x.trim()).filter(Boolean)
const body = options.clear ? { pivotTableJson: null } : { pivotTableJson: JSON.stringify(await guard(async () => buildPivot({ rows: list(options.rows), columns: list(options.columns), values: list(options.values) }, Object.keys((meta.fields ?? {}) as Json)))) }
await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: SAVED_REPORT_TABLE, primaryKey: String(report.id) } }, body: body as never }))
const label = (f: string) => String(((meta.fields ?? {}) as Record<string, Json>)[f]?.label ?? f)
process.stdout.write(`${report.label} (${report.id}) pivot table:\n${blocksToText(pivotTableBlocks({ pivotTableJson: body.pivotTableJson }, label))}\n`)
})
sr.command('schedules <report>')
.description("A saved report's schedules: cron expression and its description, time zone, recipients, format.")
.action(async (name: string) => {
const c = ctx()
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const schedules = await listReportSchedules(c.server, authenticatedFetch(c.target), report.id)
c.print({ data: schedules, rows: schedules.map((s) => ({ id: s.id, cron: s.cronExpression, description: s.cronDescription ?? '', zone: s.timeZone, active: s.isActive ? 'yes' : 'no', to: s.toAddresses, format: s.format })), columns: ['id', 'cron', 'description', 'zone', 'active', 'to', 'format'], footer: countFooter(schedules.length, 'schedule') })
})
sr.command('schedule <report>')
.description('Schedule a saved report: a cron expression (Quartz: seconds first), its time zone, recipients, subject and format.')
.requiredOption('--cron <expression>', 'e.g. "0 0 7 ? * MON-FRI"')
.requiredOption('--time-zone <zone>', 'e.g. America/Chicago')
.requiredOption('--to <addresses>', 'comma-separated email addresses')
.option('--subject <text>', 'email subject (default: the report name)')
.option('--format <fmt>', 'csv, xlsx or json', 'xlsx')
.option('--inactive', 'create it switched off')
.action(async (name: string, options: { cron: string; timeZone: string; to: string; subject?: string; format: string; inactive?: boolean }) => {
const c = ctx()
const report = await guard(async () => findSavedReport(await savedReports(c), name))
const format = await guard(async () => reportFormat(options.format))
const body = { savedReportId: report.id, cronExpression: options.cron, cronTimeZoneId: options.timeZone, toAddresses: options.to, subject: options.subject ?? report.label, format, isActive: !options.inactive }
const created = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: SCHEDULED_REPORT_TABLE } }, body: body as never })) as Json
const values = (((created.record ?? created) as Json).values ?? {}) as Json
c.print({ data: created, rows: [{ id: values.id ?? '', cron: values.cronExpression ?? options.cron, description: values.cronDescription ?? '', zone: options.timeZone }], columns: ['id', 'cron', 'description', 'zone'], footer: `scheduled ${report.label}` })
})
const share = program.command('share').description('Share a record of a shareable table (saved reports, saved views) with users or groups: list, add, scope, remove. Only the owner may share.')
share.command('list <table> <primaryKey>')
.description('Who the record is shared with, and how.')
.action(async (table: string, pk: string) => {
const c = ctx()
const meta = await c.tableMeta<Json>(table)
const shares = await guard(() => listShares(c.server, authenticatedFetch(c.target), meta, pk))
c.print({ data: shares, rows: shares.map((s) => ({ share: s.shareId, with: s.audienceLabel, type: s.audienceType, scope: scopeLabel(s.scopeId) })), columns: ['share', 'with', 'type', 'scope'], footer: countFooter(shares.length, 'share') })
})
share.command('add <table> <primaryKey> <audience>')
.description('Share the record: audience as type:id (user:jane@example.com, group:7) or an id for the first audience type of the table; --scope read-only (default) or read-write.')
.option('--scope <scope>', 'read-only or read-write', 'read-only')
.action(async (table: string, pk: string, audience: string, options: { scope: string }) => {
const c = ctx()
const meta = await c.tableMeta<Json>(table)
const types = (((meta.shareableTableMetaData ?? {}) as Json).audienceTypes ?? {}) as Record<string, Json>
const [type, id] = audience.includes(':') ? audience.split(/:(.*)/s, 2) as [string, string] : [Object.keys(types)[0] ?? 'user', audience]
if (Object.keys(types).length && !types[type]) throw new UsageError(`${table} shares with ${Object.keys(types).join(', ')}, not ${type}`)
const scopeId = await guard(async () => shareScope(options.scope))
await guard(() => addShare(c.server, authenticatedFetch(c.target), meta, pk, `${type}:${id}`, scopeId))
c.note(`Shared ${table} ${pk} with ${type} ${id} (${scopeLabel(scopeId)}).`)
})
share.command('scope <table> <primaryKey> <share> <scope>')
.description('Change a share\'s scope (read-only, read-write).')
.action(async (table: string, pk: string, shareId: string, scope: string) => {
const c = ctx()
const scopeId = await guard(async () => shareScope(scope))
await guard(async () => changeShare(c.server, authenticatedFetch(c.target), await c.tableMeta<Json>(table), pk, Number(shareId), scopeId))
c.note(`Share ${shareId} of ${table} ${pk} is now ${scopeLabel(scopeId)}.`)
})
share.command('remove <table> <primaryKey> <share>')
.description('Stop sharing (asks unless --yes).')
.action(async (table: string, pk: string, shareId: string) => {
const c = ctx()
if (!await c.confirm(`Remove share ${shareId} of ${table} ${pk}?`)) {
c.note('Not removed.')
return
}
await guard(async () => removeShare(c.server, authenticatedFetch(c.target), await c.tableMeta<Json>(table), pk, Number(shareId)))
c.note(`Removed share ${shareId} of ${table} ${pk}.`)
})
}
+152
View File
@@ -0,0 +1,152 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { Option, type Command } from 'commander'
import { decodeJwtClaims } from '@qrun-io/qqq-ui-api-client'
import { SavedViewError, buildFilter, buildViewJson, callOperation, deleteSavedView, exposedJoins, findSavedView, joinFieldNames, listSavedViews, printFilter, resolveField, savedViewProcesses, storeSavedView, variantBody, viewToQuery, type QueryFilter, type SavedView, type SavedViewProcesses } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch, currentToken, getLogin } from '../session.js'
import { ORDER_OPTION, countFooter, defaultFields, deprecatedOrderOption, orderFrom, positiveInt, readJson, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
/** The signed-in user's id as saved views record owners (email, else username or subject). */
export async function currentUserId(c: Context): Promise<string | undefined> {
const t = c.target
const token = await currentToken(t)
const claims = token && token.includes('.') ? decodeJwtClaims(token) as Json : {}
const login = await getLogin(t)
const user = login?.kind === 'session' ? login.user ?? {} : {}
const id = claims.email ?? user.email ?? user.idReference ?? user.username ?? claims.preferred_username ?? claims.sub
return id === undefined || id === null || id === '' ? undefined : String(id)
}
async function context(c: Context, table: string): Promise<{ meta: Json; processes: SavedViewProcesses; fetchImpl: typeof fetch }> {
const meta = await c.tableMeta<Json>(table)
const processes = savedViewProcesses(meta, await c.metaData() as unknown as Json)
if (!processes.query) throw new UsageError(`${meta.label} has no saved views on this server (clientCapabilities.savedViews is unavailable).`)
return { meta, processes, fetchImpl: authenticatedFetch(c.target) }
}
async function guard<T>(work: () => Promise<T>): Promise<T> {
try {
return await work()
} catch (error) {
if (error instanceof SavedViewError) throw new UsageError(error.message)
throw error
}
}
function describeFilter(view: SavedView): string {
try {
const filter = view.view.queryFilter as QueryFilter
return filter.criteria?.length || filter.subFilters?.length ? printFilter({ criteria: filter.criteria ?? [], booleanOperator: filter.booleanOperator ?? 'AND', subFilters: filter.subFilters }) : ''
} catch {
return '(unreadable filter)'
}
}
export function registerSavedView(program: Command, ctx: () => Context): void {
const sv = program.command('saved-view').description('Saved views (named query screens kept by the server): list, open, save, rename, delete.')
sv.command('list <table>')
.description("A table's saved views: yours and those shared with you, with quick views and their counts.")
.option('--counts', 'count the records of every view (quick views with doCount are always counted)')
.option(...VARIANT_OPTION)
.action(async (table: string, options: { counts?: boolean; variant?: string }) => {
const c = ctx()
const { meta, processes, fetchImpl } = await context(c, table)
const me = await currentUserId(c)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const joins = exposedJoins(meta)
const counting = options.counts || views.some((v) => v.type === 'quickView' && v.doCount)
const variant = counting ? await chooseVariant(c, meta, options.variant) : undefined
const rows = await Promise.all(views.map(async (v) => {
let count: unknown = ''
if (options.counts || (v.type === 'quickView' && v.doCount)) {
const { filter } = viewToQuery(meta, joins, v.view)
const { orderBys: _o, ...criteria } = filter
void _o
try {
count = ((await callOperation(c.server, 'countTable', { path: { tableName: table }, json: { filter: criteria, ...variantBody(variant) } }, fetchImpl)).data as Json).count ?? ''
} catch {
count = '?'
}
}
return { id: v.id, label: v.label, owner: me && v.userId === me ? 'you' : v.userId ? `shared by ${v.userId}` : '', type: v.type ?? '', filter: describeFilter(v), count }
}))
const mine = rows.filter((r) => r.owner === 'you')
const ordered = [...mine, ...rows.filter((r) => r.owner !== 'you')]
c.print({ data: views, rows: ordered, columns: ['id', 'label', 'owner', 'type', 'filter', 'count'], footer: countFooter(views.length, 'view', [!processes.store && 'saving is unavailable']) })
})
sv.command('open <table> <view>')
.description('Run a saved view: its filter, sort, columns and page size (the same as `table query <table> --view <view>`; query options are passed on).')
.allowUnknownOption()
.allowExcessArguments()
.action(async (table: string, view: string, _options: unknown, command: Command) => {
const query = program.commands.find((x) => x.name() === 'table')!.commands.find((x) => x.name() === 'query')!
await query.parseAsync([table, '--view', view, ...command.args.slice(2)], { from: 'user' })
})
sv.command('save <table>')
.description('Save a view from criteria, sort, columns and page size. A name you already use is refused unless --replace.')
.requiredOption('-n, --name <name>', 'the view name')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option(...ORDER_OPTION)
.addOption(deprecatedOrderOption())
.option('--filter-json <json|file|->', 'a QueryFilter as JSON instead')
.option('-f, --fields <names>', 'comma-separated columns to show (default: the table\'s default columns)')
.option('-l, --limit <n>', 'rows per page (the view\'s page size; as --limit on table query)')
.addOption(new Option('--page-size <n>', 'same as --limit').hideHelp())
.option('--replace', 'update your view of that name instead of refusing')
.action(async (table: string, options: { name: string; where?: string[]; group?: string[]; or?: boolean; order?: string[]; o?: string[]; filterJson?: string; fields?: string; limit?: string; pageSize?: string; replace?: boolean }) => {
const c = ctx()
options.order = orderFrom((text) => c.note(text), options)
const { meta, processes, fetchImpl } = await context(c, table)
const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter
: buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: [...Object.keys((meta.fields ?? {}) as Json), ...joinFieldNames(exposedJoins(meta))] })
const columns = c.fields ?? (options.fields ? options.fields.split(',').map((f) => f.trim()).filter(Boolean) : defaultFields(meta))
const unknown = columns.filter((f) => !resolveField(meta, exposedJoins(meta), f))
if (unknown.length) throw new UsageError(`No field ${unknown.join(', ')} in ${table}`)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const me = await currentUserId(c)
const same = views.filter((v) => v.label.toLowerCase() === options.name.toLowerCase())
if (same.length && !options.replace) throw new UsageError(`A view named "${options.name}" exists (id ${same.map((v) => v.id).join(', ')}); nothing was saved. --replace updates it.`)
const target = same.find((v) => !me || !v.userId || v.userId === me) ?? same[0]
const allColumns = Object.keys((meta.fields ?? {}) as Json).filter((n) => !((meta.fields as Record<string, Json>)[n]!.isHidden))
const view = buildViewJson({ filter, columns, allColumns, rowsPerPage: positiveInt(options.limit ?? options.pageSize ?? '25', '--limit'), base: target?.view })
const stored = await guard(() => storeSavedView(c.server, fetchImpl, processes, { table, label: options.name, view, ...(target ? { id: target.id } : {}) }))
c.print({ data: stored, rows: [{ [target ? 'updated' : 'saved']: stored.label, id: stored.id }], columns: [target ? 'updated' : 'saved', 'id'] })
})
sv.command('rename <table> <view> <newName>')
.description('Rename a view (yours; the server refuses others\').')
.action(async (table: string, idOrName: string, newName: string) => {
const c = ctx()
const { processes, fetchImpl } = await context(c, table)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const view = await guard(async () => findSavedView(views, idOrName))
const clash = views.find((v) => v.id !== view.id && v.label.toLowerCase() === newName.toLowerCase())
if (clash) throw new UsageError(`A view named "${newName}" exists (id ${clash.id}); nothing was renamed.`)
const stored = await guard(() => storeSavedView(c.server, fetchImpl, processes, { table, label: newName, view: view.view, id: view.id }))
c.print({ data: stored, rows: [{ renamed: `${view.label} -> ${stored.label}`, id: stored.id }], columns: ['renamed', 'id'] })
})
sv.command('delete <table> <view>')
.description('Delete a view (asks first; --yes skips the question).')
.action(async (table: string, idOrName: string) => {
const c = ctx()
const { processes, fetchImpl } = await context(c, table)
const view = await guard(async () => findSavedView(await listSavedViews(c.server, fetchImpl, processes, table), idOrName))
if (!await c.confirm(`Delete the view "${view.label}" (${view.id})?`)) {
c.note('Not deleted.')
return
}
await guard(() => deleteSavedView(c.server, fetchImpl, processes, view.id))
c.print({ data: { deleted: view.id }, rows: [{ deleted: view.label, id: view.id }], columns: ['deleted', 'id'] })
})
}
+252
View File
@@ -0,0 +1,252 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { spawnSync } from 'node:child_process'
import { Option } from 'commander'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chipAnsi, deniedTableReason, parseColumnList, displayDateTime, type TableAction, readFilePreferences, safeFileName, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { UsageError } from '../context.js'
export type Json = Record<string, unknown>
/** Parses `name=value` pairs (`--set`, `--values`). */
export function parsePairs(pairs: string[] | undefined, flag: string): Record<string, string> {
const values: Record<string, string> = {}
for (const pair of pairs ?? []) {
const at = pair.indexOf('=')
if (at < 1) throw new UsageError(`${flag} takes name=value: ${pair}`)
values[pair.slice(0, at)] = pair.slice(at + 1)
}
return values
}
/** Reads a JSON argument: inline text, a file path, or `-` for stdin. */
export function readJson(source: string, flag: string): unknown {
const inline = source.trim().startsWith('{') || source.trim().startsWith('[')
const text = inline ? source : new TextDecoder().decode(readFileArg(source === '-' ? 0 : source, flag))
try {
return JSON.parse(text)
} catch (error) {
throw new UsageError(`${flag}: not valid JSON (${error instanceof Error ? error.message : String(error)})`)
}
}
/**
* A record's value as text in a list cell (query rows, child lists, joined fields): the same
* adornment rules as the record view (REVEAL masked unless `reveal`, CHIP, ERROR, DATE_TIME in the
* local zone or raw with `display.utc`), without link targets or hints. Without field metadata,
* the display value when the server sent one, else the raw value.
*/
export function valueText(record: Json, field: string, meta?: Json, display?: DisplayOptions, reveal?: boolean, color?: boolean): string {
const fieldMeta = (((meta?.fields ?? {}) as Record<string, Json>)[field]) as FieldMeta | undefined
const values = (record.values ?? {}) as Record<string, unknown>
const displayValues = record.displayValues as Record<string, unknown> | undefined
if (fieldMeta) {
const shown = recordValueText({ ...fieldMeta, name: field }, { values, displayValues } as RecordLike, { cell: true, dateTime: display, reveal })
return color && shown.chip ? chipAnsi(shown.text, shown.chip.color) : shown.text
}
const value = displayValues?.[field] ?? values[field]
if (value === null || value === undefined) return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/** A DATE_TIME instant outside a record (audits, comments, extension payloads) as the record view shows one. */
export function instantText(value: unknown, display?: DisplayOptions): string {
return displayDateTime(value, undefined, display)
}
/** Reads a file a flag names (`--upload`, `--file`, `--form @file`, `--bytes`); a missing file is a usage error. */
export function readFileArg(path: string | number, flag: string): Uint8Array {
try {
return new Uint8Array(readFileSync(path))
} catch (error) {
throw new UsageError(`${flag}: cannot read ${path === 0 ? 'stdin' : path} (${error instanceof Error ? error.message : String(error)})`)
}
}
/**
* Where a download goes when no --out / --out-dir names a place: the download directory of the
* file preferences the TUI shares (`qqq config set download-dir`), created when missing, else the
* current directory.
*/
export function downloadDir(): string {
const dir = readFilePreferences().downloadDir
if (!dir) return '.'
mkdirSync(dir, { recursive: true })
return dir
}
/**
* The one way a command writes a downloaded file: `-` writes to stdout; a path the user named is
* written as given (replacing a file); otherwise `defaultName` (already a safe file name) goes
* into the download directory without replacing anything (the date and time go before the extension, then -1, -2, …). Returns the path
* written, or undefined for stdout.
*/
export function writeDownload(out: string | undefined, defaultName: string, bytes: Uint8Array): string | undefined {
if (out === '-') {
process.stdout.write(bytes)
return undefined
}
if (out !== undefined) return saveDownload(out, bytes, { explicit: true })
const dir = downloadDir()
return saveDownload(dir === '.' ? defaultName : join(dir, defaultName), bytes, { explicit: false })
}
/** `n record`/`n records`. */
export function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
return `${n} ${n === 1 ? singular : pluralForm}`
}
/**
* The flag convention, the same on every command: `-o` / `--out` is the output file; the sort is
* `--order` (no short form). Until 1.1, `-o` still means the sort on commands that write no file
* (table query, saved-view save, saved-report preview and set), with a deprecation note.
*/
export const ORDER_OPTION = ['--order <field...>', 'order by fields; prefix - for descending'] as const
/** The hidden `-o <field...>` that still sorts on commands without an output file (deprecated in 1.0). */
export function deprecatedOrderOption(): Option {
return new Option('-o <field...>', 'deprecated: use --order (-o is the output file on every command from 1.1)').hideHelp()
}
const FILE_EXTENSION = /\.(csv|tsv|txt|xlsx|xls|json|ya?ml|xml|pdf|html?|zip|gz)$/i
/** Whether a value given as a sort field looks like a file path (`out.csv`, `./x`, `/tmp/y`). */
export function looksLikePath(value: string): boolean {
return value.includes('/') || value.includes('\\') || value.startsWith('~') || value.startsWith('.') || FILE_EXTENSION.test(value)
}
/**
* The sort fields of a command: --order plus the deprecated `-o` (noted), with a warning for a
* value that looks like a file path (the old `-o` ambiguity: `export -o out.csv` used to sort).
*/
export function orderFrom(note: (text: string) => void, options: { order?: string[]; o?: string[] }): string[] | undefined {
if (options.o?.length) note('warning: -o for the sort order is deprecated; use --order (from 1.1, -o means --out, the output file, on every command).')
const order = [...(options.order ?? []), ...(options.o ?? [])]
for (const value of order) {
if (looksLikePath(value.replace(/^-/, ''))) note(`warning: the sort field "${value}" looks like a file path; --order takes field names (prefix - for descending). To write a file use -o/--out (table export), or redirect the output.`)
}
return order.length ? order : undefined
}
/**
* The columns of a list command: --columns (or the global --fields), else its defaults. A chosen
* column the prepared rows do not have is read from the item as the server sent it.
*/
export function chooseColumns(given: string | undefined, fields: string[] | undefined, defaults: string[], rows: Json[], items: readonly unknown[]): { rows: Json[]; columns: string[] } {
const chosen = parseColumnList(given) ?? fields
if (!chosen) return { rows, columns: defaults }
return { rows: rows.map((row, i) => ({ ...(items[i] as Json | undefined), ...row })), columns: chosen }
}
/** The --columns option of list commands. */
export const COLUMNS_OPTION = ['--columns <names>', 'columns to show, comma-separated (also any field of the item as the server sends it)'] as const
/** The footer every list ends with: `(3 records; joined: pet; more may exist: …)`. */
export function countFooter(n: number, singular: string, parts: Array<string | number | false | undefined | null> = [], pluralForm?: string): string {
return `(${[plural(n, singular, pluralForm), ...parts.filter(Boolean)].join('; ')})`
}
/** A whole number option (`--limit`, `--skip`, …), checked: a usage error otherwise. */
export function intOption(text: string | undefined, flag: string, min = 0): number | undefined {
if (text === undefined) return undefined
const n = Number(text)
if (!Number.isInteger(n) || n < min) throw new UsageError(`${flag} must be a whole number${min > 0 ? ` of at least ${min}` : ' (0 or more)'}: ${text}`)
return n
}
/** A positive whole number option. */
export function positiveInt(text: string, flag: string): number {
return intOption(text, flag, 1)!
}
/** The fields a query shows by default: the primary key, then the sections' fields by tier, then the rest; capped. */
export function defaultFields(meta: Json, cap = 8): string[] {
const fields = (meta.fields ?? {}) as Record<string, Json>
const pk = String(meta.primaryKeyField ?? 'id')
const order: string[] = [pk]
const sections = (meta.sections ?? []) as Json[]
for (const tier of ['T1', 'T2', 'T3']) {
for (const section of sections.filter((s) => s.tier === tier)) {
for (const name of (section.fieldNames ?? []) as string[]) if (!order.includes(name) && fields[name] && !fields[name].isHidden) order.push(name)
}
}
for (const name of Object.keys(fields)) if (!order.includes(name) && !fields[name]!.isHidden && !fields[name]!.isHeavy) order.push(name)
return order.slice(0, cap)
}
/** Field labels for column headers. */
export function labelsFor(meta: Json, names: string[]): string[] {
const fields = (meta.fields ?? {}) as Record<string, Json>
return names.map((n) => String(fields[n]?.label ?? n))
}
/**
* The temporary file name for editing: the name's characters other than letters, digits, `.`, `_`
* and `-` become `_`. The name carries a table, primary key and field, which can hold anything.
*/
export function editFileName(name: string): string {
return safeFileName(name).replace(/[^A-Za-z0-9._-]/g, '_')
}
/**
* Runs the editor command on a file. $VISUAL / $EDITOR is a command line (`code --wait`), so it
* goes through a shell; the path never becomes part of that command text: on POSIX it is the
* shell's `$1` (as git runs its editor), on Windows the quoted `%QQQ_EDIT_FILE%` variable (a
* Windows path cannot hold the `"` that would end the quotes).
*/
export function runEditor(editor: string, path: string): ReturnType<typeof spawnSync> {
if (process.platform === 'win32') return spawnSync(`${editor} "%QQQ_EDIT_FILE%"`, { stdio: 'inherit', shell: true, env: { ...process.env, QQQ_EDIT_FILE: path } })
return spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'qqq-editor', path], { stdio: 'inherit' })
}
/** Opens $VISUAL / $EDITOR (else vi) on a text and returns what was saved. */
export function editText(text: string, name: string): string {
const dir = mkdtempSync(join(tmpdir(), 'qqq-edit-'))
const path = join(dir, editFileName(name))
try {
writeFileSync(path, text, { mode: 0o600 })
const editor = process.env.VISUAL || process.env.EDITOR || 'vi'
const result = runEditor(editor, path)
if (result.status !== 0) throw new UsageError(`${editor} exited with ${result.status ?? result.signal}; nothing was stored.`)
return readFileSync(path, 'utf8')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
/** Whether CHIP values are colored: table output on a terminal, unless NO_COLOR is set. */
export function useColor(format: string): boolean {
return format === 'table' && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
}
/**
* Puts text on the clipboard: pbcopy (macOS), clip (Windows), wl-copy or xclip/xsel (Linux), else
* the OSC 52 escape to the terminal (most terminals and tmux accept it). Returns how.
*/
export function copyToClipboard(text: string): string {
const tries: Array<[string, string[]]> = process.platform === 'darwin' ? [['pbcopy', []]]
: process.platform === 'win32' ? [['clip', []]]
: [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]]
for (const [cmd, args] of tries) {
const result = spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] })
if (result.status === 0) return `with ${cmd}`
}
if (!process.stderr.isTTY) throw new UsageError('No clipboard tool (pbcopy, clip, wl-copy, xclip, xsel) and no terminal for OSC 52.')
process.stderr.write(`\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`)
return 'through the terminal (OSC 52)'
}
/**
* Refuses an action the table's metadata denies (a DISABLED table is listed with its permissions
* false), exit 3 with the reason, before any request; the server would refuse it too.
*/
export function requireTable(meta: Json, action: TableAction): void {
const reason = deniedTableReason(meta, action)
if (reason) throw new UiApiError(403, { code: 'PERMISSION_DENIED', message: reason })
}
+723
View File
@@ -0,0 +1,723 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { basename } from 'node:path'
import type { Command } from 'commander'
import { exportFileName, deniedTableReason, dynamicFormFields, dynamicFormMissing, dynamicFormSubmission, dynamicFormValues, readViewSettings, writeViewSettings, type DynamicFormPayload, withJoinFields, AssociationError, FilterLabelError, resolveFilterLabels, associationJoin, buildFilter, columnStatsAvailability, combineWithQuickSearch, computeColumnStats, computeLoadedColumnStats, type LoadedColumnStats, copyRecordValues, defaultJoinColumns, exposedJoins, fieldLabel, filterDateTimesToUtc, findRecordByKey, findSavedView, FormInputError, FormSession, gotoKeys, isGotoKey, JoinError, joinFieldNames, listSavedViews, listTableVariants, parentLinkValues, planJoins, printFilter, quickSearchFields, quickSearchFilter, relatedRecordTarget, resolveField, runColumnStats, safeFileName, saveDownload, SavedViewError, savedViewProcesses, variantBody, variantParam, viewToQuery, type ExposedJoin, type FieldMeta, type QueryFilter, type QueryJoin, type RecordLike, type TableMetaLike } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { promptForm } from '../prompt.js'
import { authenticatedFetch } from '../session.js'
import { UsageError, type Context } from '../context.js'
import { ORDER_OPTION, copyToClipboard, countFooter, deprecatedOrderOption, orderFrom, defaultFields, editText, useColor, intOption, parsePairs, positiveInt, readFileArg, readJson, valueText, writeDownload, requireTable, type Json } from './shared.js'
import { getRecord, showRecord } from './related.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
export interface QueryOptions { caseSensitive?: boolean; where?: string[]; group?: string[]; or?: boolean; order?: string[]; o?: string[]; limit?: string; skip?: string; all?: boolean; pageSize: string; fields?: string; filterJson?: string; printFilter?: boolean; count?: boolean; search?: string; variant?: string; join?: string[]; select?: boolean; view?: string; reveal?: boolean }
/** The formats the export route writes. */
const EXPORT_FORMATS = ['csv', 'xlsx', 'json', 'tsv']
/**
* The joins a command may use: the table's exposed joins, plus, when a join is asked for that is not
* exposed, the table's associations (each joined table's metadata fetched).
*/
async function availableJoins(c: Context, meta: Json, options: { join?: string[]; where?: string[]; group?: string[]; order?: string[]; filterJson?: string }, columns?: string[]): Promise<ExposedJoin[]> {
const exposed = exposedJoins(meta, (await c.metaData() as unknown as Json).tables as Record<string, Json> | undefined)
const text = [...(options.join ?? []), ...(columns ?? []), ...(options.where ?? []), ...(options.group ?? []), ...(options.order ?? []), options.filterJson ?? ''].join(' ')
const wanted = (meta.associations ?? []) as Json[]
const extra: ExposedJoin[] = []
for (const association of wanted) {
const table = String(association.associatedTableName ?? '')
if (!table || exposed.some((j) => j.table === table) || extra.some((j) => j.table === table)) continue
if (!(options.join ?? []).includes(table) && !text.includes(`${table}.`)) continue
const join = associationJoin(association, await c.tableMeta<Json>(table))
if (join) extra.push(join)
}
return [...exposed, ...extra]
}
async function tableMeta(c: Context, table: string): Promise<Json> {
return c.tableMeta<Json>(table)
}
function filterFrom(options: QueryOptions, meta: Json, columns?: string[], joins: ExposedJoin[] = []): QueryFilter {
const knownFields = [...Object.keys((meta.fields ?? {}) as Json), ...joinFieldNames(joins)]
// --where / --group values on DATE_TIME fields are local times (or dates: local midnight), sent as UTC instants; --filter-json is sent as given
const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter
: filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields, caseSensitive: options.caseSensitive }), (meta.fields ?? {}) as Record<string, FieldMeta>)
if (options.search === undefined) return filter
// quick search as the web UI does it: CONTAINS on each shown string field, ORed, ANDed onto the filter
const fields = quickSearchFields(meta, columns)
if (!fields.length) throw new UsageError(`--search: ${meta.label} has no text fields to search${columns ? ' among --fields' : ''}`)
return combineWithQuickSearch(filter, quickSearchFilter(options.search, fields, { caseSensitive: options.caseSensitive }))
}
/**
* filterFrom, with labels typed for possible-value fields (`homeCityId = 'New York'`) resolved
* to ids as the web filter sends them (--filter-json is sent as given).
*/
export async function resolvedFilterFrom(c: Context, options: QueryOptions, meta: Json, columns?: string[], joins: ExposedJoin[] = []): Promise<QueryFilter> {
const filter = filterFrom(options, meta, columns, joins)
if (options.filterJson) return filter
try {
const { filter: resolved, notes } = await resolveFilterLabels(c.server, authenticatedFetch(c.target), String(meta.name), (meta.fields ?? {}) as Record<string, FieldMeta>, filter)
for (const n of notes) c.note(`filter: ${n}`)
return resolved
} catch (error) {
if (error instanceof FilterLabelError) throw new UsageError(`--where: ${error.message}`)
throw error
}
}
/** A file extension for editing a field in $EDITOR: html for HTML, the code editor's language, else txt. */
/** The statistic rows of columns computed here from records: the figures the field's kind has. */
function loadedStatsRows(stats: LoadedColumnStats): Array<{ statistic: string; value: unknown }> {
const row = (statistic: string, value: unknown) => ({ statistic, value: value ?? '' })
const rows = [row('Count', stats.count), row('Nulls', stats.nulls), row('Distinct', stats.distinct)]
if (stats.kind === 'number') rows.push(row('Sum', stats.sum), row('Mean', stats.mean), row('Median', stats.median), row('Min', stats.min), row('Max', stats.max))
else if (stats.kind === 'date') rows.push(row('Earliest', stats.min), row('Latest', stats.max))
else {
rows.push(row('Min', stats.min), row('Max', stats.max))
if (stats.kind === 'string') rows.push(row('Shortest', stats.shortest), row('Longest', stats.longest))
}
return rows
}
export function editorExtension(field: FieldMeta): string {
if (field.type === 'HTML') return 'html'
const code = (field.adornments ?? []).find((a) => a.type === 'CODE_EDITOR') as { values?: Record<string, unknown> } | undefined
const mode = String(code?.values?.languageMode ?? '').toLowerCase()
return ({ javascript: 'js', json: 'json', velocity: 'vm', sql: 'sql', yaml: 'yaml', xml: 'xml', html: 'html', python: 'py', typescript: 'ts' } as Record<string, string>)[mode] ?? (code ? 'txt' : 'txt')
}
/** The joins a query or count needs (--join, plus joined names in columns and the filter). */
function joinsFrom(options: QueryOptions, available: ExposedJoin[], columns: string[] | undefined, filter: QueryFilter): { joins?: QueryJoin[]; used: ExposedJoin[]; includeDistinct: boolean } {
try {
const plan = planJoins(available, { named: options.join, select: options.select, columns, filter })
return { ...(plan.joins.length ? { joins: plan.joins } : {}), used: plan.used, includeDistinct: plan.includeDistinct }
} catch (error) {
if (error instanceof JoinError) throw new UsageError(error.message)
throw error
}
}
/** Prints a count, with the distinct count when a many-side join can repeat records. */
function printCount(c: Context, result: Json, includeDistinct: boolean): void {
const row: Json = { count: result.count }
if (includeDistinct) row.distinctCount = result.distinctCount ?? ''
c.print({ data: result, rows: [row], columns: Object.keys(row) })
}
/** Opens a saved view for a query: the view's filter, columns and page size, with Material's reconcile warnings on stderr. */
async function openSavedView(c: Context, meta: Json, idOrName: string): Promise<{ label: string; filter: QueryFilter; columns: string[]; rowsPerPage?: number }> {
const metaData = await c.metaData() as unknown as Json
const processes = savedViewProcesses(meta, metaData)
try {
const view = findSavedView(await listSavedViews(c.server, authenticatedFetch(c.target), processes, String(meta.name)), idOrName)
const joins = await availableJoins(c, meta, { filterJson: JSON.stringify(view.view) })
const opened = viewToQuery(meta, joins, view.view)
for (const warning of opened.warnings) c.note(`warning: ${warning}`)
return { label: view.label, ...opened }
} catch (error) {
if (error instanceof SavedViewError) throw new UsageError(error.message)
throw error
}
}
/** ANDs the command's criteria onto a view's filter; the command's sort replaces the view's. */
function withSavedView(view: QueryFilter | undefined, given: QueryFilter): QueryFilter {
if (!view) return given
const { orderBys: viewOrder, ...viewRest } = view
const { orderBys: givenOrder, ...givenRest } = given
const orderBys = givenOrder?.length ? givenOrder : viewOrder
const empty = (f: Partial<QueryFilter>) => !f.criteria?.length && !f.subFilters?.length
const base: QueryFilter = empty(givenRest) ? viewRest as QueryFilter : empty(viewRest) ? givenRest as QueryFilter : { criteria: [], booleanOperator: 'AND', subFilters: [viewRest as QueryFilter, givenRest as QueryFilter] }
return orderBys?.length ? { ...base, orderBys } : base
}
/** --fields of the command (or the global one), as a list. */
function columnsFrom(c: Context, options: { fields?: string }): string[] | undefined {
return c.fields ?? (options.fields ? options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined)
}
export function registerTable(program: Command, ctx: () => Context): void {
const table = program.command('table').description('Tables: list, describe, variants, query, count, stats, get, create, copy, update, delete, export.')
table.command('list')
.description('List the tables you may read (optionally of one app).')
.option('-a, --app <name>', 'only tables in this app')
.action(async (options: { app?: string }) => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
// a DISABLED table (deny behavior) is listed with its permissions false: shown with the reason
let tables = Object.values((metaData.tables ?? {}) as Record<string, Json>).filter((t) => !t.isHidden)
if (options.app) {
const app = (metaData.apps as Record<string, Json> | undefined)?.[options.app]
if (!app) throw new UsageError(`No app named ${options.app} (see \`qqq apps\`)`)
const names = new Set<unknown>()
const visit = (node: Json | undefined, seen: Set<unknown>) => {
for (const child of (node?.children ?? []) as Json[]) {
if (child.type === 'TABLE') names.add(child.name)
else if (child.type === 'APP' && !seen.has(child.name)) visit((metaData.apps as Record<string, Json>)[String(child.name)], seen.add(child.name))
}
}
visit(app, new Set([options.app]))
tables = tables.filter((t) => names.has(t.name))
}
const rows = tables.map((t) => ({ name: t.name, label: t.label, insert: t.insertPermission ? 'yes' : '', edit: t.editPermission ? 'yes' : '', delete: t.deletePermission ? 'yes' : '', note: deniedTableReason(t, 'read') ?? '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
const disabled = rows.some((r) => r.note)
c.print({ data: rows, columns: ['name', 'label', 'insert', 'edit', 'delete', ...(disabled ? ['note'] : [])] })
})
table.command('describe <table>')
.description("A table's fields, sections and capabilities.")
.action(async (name: string) => {
const c = ctx()
const meta = await tableMeta(c, name)
const fields = Object.values((meta.fields ?? {}) as Record<string, Json>).map((f) => ({
name: f.name, label: f.label, type: f.type, required: f.isRequired ? 'yes' : '', editable: f.isEditable === false ? 'no' : 'yes',
possibleValues: f.possibleValueSourceName ?? '', hidden: f.isHidden ? 'yes' : '',
}))
const sections = ((meta.sections ?? []) as Json[]).map((s) => `${s.tier} ${s.label}: ${((s.fieldNames ?? []) as string[]).join(', ')}`).join('\n')
c.print({ data: meta, rows: fields, columns: ['name', 'label', 'type', 'required', 'editable', 'possibleValues', 'hidden'], footer: `${meta.label} (${meta.name}), primary key ${meta.primaryKeyField}\n${sections}\ncapabilities: ${((meta.capabilities ?? []) as string[]).join(', ')}` })
})
table.command('variants <table>')
.description('The variants a table\'s backend can be scoped to (give one to other commands with --variant).')
.action(async (name: string) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variants = await listTableVariants(c.server, authenticatedFetch(c.target), name)
c.print({ data: variants, rows: variants.map((v) => ({ id: v.id, name: v.name ?? '', type: v.type })), columns: ['id', 'name', 'type'], footer: meta.usesVariants ? `${meta.label} needs a ${String(meta.variantTableLabel ?? 'variant')}: --variant <id|name>` : `${meta.label} does not use variants${variants.length ? '' : ' (none listed)'}` })
})
const query = table.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]" (operators as the UI API, or = != < <= > >= ~); values may be now(-7d), thisPeriod(month), startOf(quarter,-1), me(), ${variable.x}.')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses, e.g. "(a = 1 AND b = 2) OR c IS_BLANK"')
.option('--or', 'OR the --where criteria instead of AND')
.option('--case-sensitive', 'text matches (~, CONTAINS, STARTS_WITH, …, --search) match case; by default they ignore it (CASE_INSENSITIVE)')
.option(...ORDER_OPTION)
.addOption(deprecatedOrderOption())
.option('-l, --limit <n>', 'most records to return (default: the view\'s page size, else 25)')
.option('--skip <n>', 'records to skip')
.option('--all', 'fetch every page (with --page-size)')
.option('--page-size <n>', 'page size for --all', '500')
.option('-f, --fields <names>', 'comma-separated fields to show')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON, instead of --where/--group/--order')
.option('--print-filter', 'print the QueryFilter JSON the options produced and stop')
.option('--count', 'print only the count of matching records')
.option('-S, --search <text>', 'quick search: records whose text fields contain this, ANDed with the filter (as the web UI search box)')
.option('-V, --view <id|name>', 'start from a saved view: its filter, sort, columns and page size (--where and --search are ANDed on, --order and --fields replace)')
.option('-j, --join <table...>', 'join exposed joins (by joined table name or label); joined fields are named table.field in --fields, --where and --order')
.option('--select', 'select the --join tables\' fields (shown columns from a join select it anyway)')
.option('--reveal', 'show REVEAL (masked) values in table, text and csv output')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
options.order = orderFrom((text) => c.note(text), options)
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
const limitOption = intOption(options.limit, '--limit', 1)
const pageSize = positiveInt(options.pageSize, '--page-size')
const saved = options.view !== undefined ? await openSavedView(c, meta, options.view) : undefined
const known = (meta.fields ?? {}) as Record<string, Json>
const remembered = (readViewSettings(`table:${name}`).columns ?? []).filter((f) => f.includes('.') || known[f])
const given = columnsFrom(c, options) ?? (saved?.columns.length ? saved.columns : undefined) ?? (remembered.length ? remembered : undefined)
const available = await availableJoins(c, meta, { ...options, filterJson: `${options.filterJson ?? ''} ${saved ? JSON.stringify(saved.filter) : ''}` }, given)
const filter = withSavedView(saved?.filter, await resolvedFilterFrom(c, options, meta, given, available))
const { joins, used, includeDistinct } = joinsFrom(options, available, given, filter)
if (options.printFilter) {
c.print({ data: joins ? { filter, joins } : filter })
return
}
const variant = await chooseVariant(c, meta, options.variant)
if (options.count) {
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct } }, body: { filter: filter as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) } })) as Json
printCount(c, result, includeDistinct)
return
}
const records: Json[] = []
const limit = options.all ? pageSize : limitOption ?? saved?.rowsPerPage ?? 25
let skip = intOption(options.skip, '--skip') ?? 0
for (;;) {
const body = { filter: { ...filter, skip, limit } as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) }
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: name } }, body })) as Json
const page = (result.records ?? []) as Json[]
records.push(...page)
if (!options.all || page.length < limit) break
skip += limit
}
const selected = used.filter((j) => joins?.find((q) => q.joinTable === j.table)?.select || options.select)
const fields = given ?? [...defaultFields(meta), ...selected.flatMap((j) => defaultJoinColumns(j))]
const joinsShown = used.map((j) => j.table)
const shownMeta = withJoinFields(meta, used)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, shownMeta, c.dateTimeDisplay, options.reveal, useColor(c.format))])))
c.print({ data: records, rows, columns: fields, headers: fields.map((f) => fieldLabel(meta, used, f)), footer: countFooter(records.length, 'record', [
joinsShown.length && `joined: ${joinsShown.join(', ')}${includeDistinct ? ' (many-side: rows may repeat a record; --count gives the distinct count)' : ''}`,
variant && `variant ${variant.name ?? `${variant.type}:${variant.id}`}`,
saved && `view ${saved.label}`,
!columnsFrom(c, options) && !saved?.columns.length && remembered.length && `your columns (qqq table columns ${name} --reset)`,
(filter.criteria?.length || filter.subFilters?.length) && `filter: ${printFilter(filter)}`,
!options.all && records.length === limit && 'more may exist: --limit, --skip or --all',
]) })
})
table.command('widget-form <table> <primaryKey> <widget>')
.description('Edit the fields of a dynamicForm widget on a record\'s form (--set, or -i on a terminal): required fields are checked, and the values are saved in the record field the widget names (a JSON object) or as record fields.')
.option('-s, --set <name=value...>', 'form field values (an empty value clears)')
.option('-i, --interactive', 'prompt for every form field, current values shown')
.action(async (name: string, primaryKey: string, widgetName: string, options: { set?: string[]; interactive?: boolean }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'edit')
const widgetMeta = (((await c.metaData()) as unknown as Json).widgets as Record<string, Json> | undefined)?.[widgetName]
if (!widgetMeta) throw new UsageError(`No widget "${widgetName}"`)
const onTable = ((meta.sections ?? []) as Json[]).some((section) => section.widgetName === widgetName)
if (!onTable) throw new UsageError(`${widgetName} is not a widget on ${meta.label}'s form`)
const pkField = String(meta.primaryKeyField ?? 'id')
const payload = (await c.call('getWidgetData', { path: { widgetName }, json: { [pkField]: primaryKey } })).data as DynamicFormPayload
if ((payload.type ?? widgetMeta.type) !== 'dynamicForm') throw new UsageError(`${widgetName} is a ${String(payload.type ?? widgetMeta.type)} widget, not a dynamicForm`)
const fields = dynamicFormFields(payload)
if (!fields.length) throw new UsageError(payload.noFieldsMessage ?? `${widgetName} has no fields to edit`)
const record = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey } } })) as Json
const current = dynamicFormValues(payload, (((record.record ?? record) as Json).values ?? {}) as Json)
const session = new FormSession({ baseUrl: c.server, fetch: authenticatedFetch(c.target), scope: { table: name }, fields, initialValues: current })
for (const [field, value] of Object.entries(parsePairs(options.set, '--set'))) {
if (!fields.some((f) => f.name === field)) throw new UsageError(`${field}: not a field of ${widgetName} (${fields.map((f) => f.name).join(', ')})`)
try {
await session.set(field, value === '' ? null : value)
} catch (error) {
if (error instanceof FormInputError) throw new UsageError(error.message)
throw error
}
}
if (options.interactive) await promptForm(session)
else if (!options.set?.length) throw new UsageError('Give values with --set name=value, or -i on a terminal.')
const missing = dynamicFormMissing(payload, session.values)
if (missing.length) throw new UsageError(`${widgetName} needs ${missing.join(', ')}`)
const problems = session.validate()
if (Object.keys(problems).length) validationError(session, problems)
const body = dynamicFormSubmission(payload, session.values)
await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey } }, body: body as never }))
c.print({ data: body, rows: fields.map((f) => ({ field: f.label, value: session.values[f.name] ?? '' })), columns: ['field', 'value'], footer: `saved in ${Object.keys(body).join(', ')} of ${meta.label} ${primaryKey}` })
})
table.command('columns <table> [fields...]')
.description('The columns `table query` shows for this table, kept between commands (shared with the TUI\'s column chooser): no fields shows them, fields (in order) set them, --reset goes back to the defaults. --fields on a query still wins.')
.option('--reset', 'forget the saved columns')
.action(async (name: string, fields: string[], options: { reset?: boolean }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const key = `table:${name}`
const view = readViewSettings(key)
const known = (meta.fields ?? {}) as Record<string, Json>
if (options.reset) {
const { columns: _dropped, ...rest } = view
writeViewSettings(key, Object.keys(rest).length ? rest : undefined)
} else if (fields.length) {
const names = fields.flatMap((f) => f.split(',')).map((f) => f.trim()).filter(Boolean).map((f) => {
if (known[f] || f.includes('.')) return f
const byLabel = Object.values(known).find((x) => String(x.label ?? '').toLowerCase() === f.toLowerCase())
if (byLabel) return String(byLabel.name)
throw new UsageError(`${f}: not a field of ${meta.label} (${Object.keys(known).join(', ')})`)
})
writeViewSettings(key, { ...view, columns: names })
}
const columns = readViewSettings(key).columns
const shown = columns?.length ? columns : defaultFields(meta)
c.print({ data: { table: name, columns: shown, saved: Boolean(columns?.length) }, rows: shown.map((f, i) => ({ n: i + 1, field: f, label: known[f]?.label ?? f })), columns: ['n', 'field', 'label'], footer: columns?.length ? `(saved columns for ${name}; --reset for the defaults)` : `(the default columns for ${name})` })
})
table.command('stats <table> <field>')
.description('Column statistics over the matching records: count, distinct, sum, average, min, max, and the distribution of values (the server\'s columnStats process; tables need QUERY_STATS).')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--case-sensitive', 'text matches (~, CONTAINS, STARTS_WITH, …, --search) match case; by default they ignore it (CASE_INSENSITIVE)')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option('-j, --join <table...>', 'join exposed joins (the field may be table.field)')
.option('--sort <order>', 'distribution order: count (default, most first), count-asc, value, value-desc', 'count')
.option('-l, --limit <n>', 'most distribution rows to show', '25')
.option('--local', 'compute the statistics here from the matching records (paged), even when the server offers them; without it this is the fallback when the server offers none (or answers 403 or 404)')
.option('--no-local', 'never compute here: a server without column statistics is an error')
.option('--max-records <n>', 'when computing here, the most records to read', '10000')
.option(...VARIANT_OPTION)
.action(async (name: string, field: string, options: QueryOptions & { sort: string; local?: boolean; maxRecords: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
const available = await availableJoins(c, meta, { ...options, join: [...(options.join ?? []), ...(field.includes('.') ? [field.slice(0, field.indexOf('.'))] : [])] }, [field])
const resolved = resolveField(meta, available, field)
if (!resolved) throw new UsageError(`No field "${field}" in ${name}`)
const filter = await resolvedFilterFrom(c, options, meta, undefined, available)
const orderField = { count: 'count.desc', 'count-asc': 'count.asc', value: `${field}.asc`, 'value-desc': `${field}.desc` }[options.sort]
if (!orderField) throw new UsageError('--sort takes count, count-asc, value or value-desc')
const variant = await chooseVariant(c, meta, options.variant)
const availability = columnStatsAvailability(meta, await c.metaData() as unknown as Json)
let stats
let loaded: LoadedColumnStats | undefined
let serverReason = availability.reason
if (availability.available && options.local !== true) {
try {
stats = await runColumnStats(c.server, authenticatedFetch(c.target), { table: name, field, filter, orderBy: orderField, tableVariant: variant })
} catch (e) {
// the server says no (403, 404): compute here unless --no-local
if (!(e instanceof UiApiError && (e.status === 403 || e.status === 404)) || options.local === false) throw e
serverReason = `the server refused column statistics (${e.status})`
}
}
if (!stats) {
if (options.local === false) throw new UsageError(`No column statistics: ${serverReason}. Without --no-local they are computed here from the matching records.`)
const { joins } = joinsFrom(options, available, [field], filter)
const records: Json[] = []
const pageSize = 1000
const max = positiveInt(options.maxRecords, '--max-records')
for (let skip = 0; skip < max; skip += pageSize) {
const body = { filter: { ...filter, skip, limit: Math.min(pageSize, max - skip) } as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) }
const page = ((await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: name } }, body })) as Json).records ?? []) as Json[]
records.push(...page)
if (page.length < pageSize) break
}
const capped = records.length >= max
c.note(`from ${records.length.toLocaleString('en-US')} matching records (the server offers no column statistics for ${String(meta.label ?? name)})${capped ? ` · stopped at --max-records ${max}; the statistics cover those records only` : ''}`)
stats = computeColumnStats(records, field, resolved.field, orderField)
loaded = computeLoadedColumnStats(records as never, field, resolved.field)
}
const label = fieldLabel(meta, available, field)
const limit = positiveInt(options.limit ?? '25', '--limit')
const distribution = stats.valueCounts.slice(0, limit).map((r) => ({ value: r.displayValues[field] ?? r.values[field] ?? '', count: r.values.count }))
if (c.format === 'json' || c.format === 'yaml' || c.options.query) {
c.print({ data: { field, label, ...(loaded ? { source: 'matching records', loaded } : {}), ...stats } })
return
}
const loadedRows = loaded && loadedStatsRows(loaded)
if (loadedRows) {
c.print({ data: stats, rows: loadedRows, columns: ['statistic', 'value'], footer: `${meta.label}: ${label}${resolved.field.type === 'DATE_TIME' ? ' (grouped by hour)' : ''} (computed locally)` })
c.print({ data: stats.valueCounts, rows: distribution, columns: ['value', 'count'], headers: [label, 'Count'], footer: countFooter(distribution.length, 'value', [stats.valueCounts.length > limit && `${stats.valueCounts.length} in all; --limit for more`]) })
return
}
c.print({ data: stats, rows: stats.fields.map((f) => ({ statistic: f.label, value: stats.stats.displayValues[f.name] ?? stats.stats.values[f.name] ?? '' })), columns: ['statistic', 'value'], footer: `${meta.label}: ${label}${resolved.field.type === 'DATE_TIME' ? ' (grouped by hour)' : ''}${availability.available ? '' : ' (computed locally)'}` })
c.print({ data: stats.valueCounts, rows: distribution, columns: ['value', 'count'], headers: [label, 'Count'], footer: countFooter(distribution.length, 'value', [stats.valueCounts.length > limit && `${stats.valueCounts.length} in all; --limit for more`]) })
})
table.command('count <table>')
.description('Count the records matching criteria.')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--case-sensitive', 'text matches (~, CONTAINS, STARTS_WITH, …, --search) match case; by default they ignore it (CASE_INSENSITIVE)')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option('-j, --join <table...>', 'join exposed joins (by joined table name or label); joined fields are named table.field in --fields, --where and --order')
.option('--select', 'select the --join tables\' fields (shown columns from a join select it anyway)')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
const available = await availableJoins(c, meta, options)
const filter = await resolvedFilterFrom(c, options, meta, undefined, available)
const { joins, includeDistinct } = joinsFrom(options, available, undefined, filter)
const variant = await chooseVariant(c, meta, options.variant)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct } }, body: { filter: filter as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) } })) as Json
printCount(c, result, includeDistinct)
})
table.command('get <table> [primaryKey]')
.description('View one record by section, values through their adornments (links, chips, masked values, files): by primary key, or by a unique key with --key field=value (the web UI\'s Go To); --associations adds the child records.')
.option('--associations', 'list every association (child records, all levels) as tables')
.option('-k, --key <field=value...>', 'find the record by key fields instead of the primary key (one match required)')
.option('--follow <field>', 'open the record a link or possible-value field points at, instead of this one')
.option('--links', 'list the related records this record links to (fields, tables, keys)')
.option('--reveal', 'show REVEAL (masked) values')
.option('--help-content', 'show table, section and field help content')
.option('-j, --join <table...>', 'include fields of these exposed joins (table.field)')
.option('--copy <field>', 'copy the field\'s value (a REVEAL value unmasked) to the clipboard instead of showing the record')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKeyArg: string | undefined, options: { associations?: boolean; key?: string[]; follow?: string; links?: boolean; reveal?: boolean; helpContent?: boolean; join?: string[]; copy?: string; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
const variant = await chooseVariant(c, meta, options.variant)
const plan = joinsFrom({ join: options.join, select: true } as QueryOptions, await availableJoins(c, meta, { join: options.join }), undefined, { criteria: [], booleanOperator: 'AND' })
let primaryKey = primaryKeyArg
if (options.key?.length) {
if (primaryKey !== undefined) throw new UsageError('Give a primary key or --key, not both.')
const values = parsePairs(options.key, '--key')
for (const field of Object.keys(values)) if (!(meta.fields as Json)[field]) throw new UsageError(`No field "${field}" in ${name}`)
if (!isGotoKey(meta, Object.keys(values))) c.note(`note: ${Object.keys(values).join('+')} is not a declared key of ${meta.label} (keys: ${gotoKeys(meta).map((k) => k.join('+')).join(', ')}); looking it up anyway`)
const found = await findRecordByKey(c.server, authenticatedFetch(c.target), meta, values, { tableVariant: variant })
if (found.found === 'none') throw new UiApiError(404, { code: 'NOT_FOUND', message: 'Record not found.' })
if (found.found === 'many') throw new UsageError(`More than 1 record was found for ${Object.entries(values).map(([k, v]) => `${k}=${v}`).join(', ')}; it is not a unique key (use table query).`)
primaryKey = found.primaryKey
}
if (primaryKey === undefined) throw new UsageError('Give a primary key, or --key field=value.')
const show = { associations: options.associations, links: options.links, reveal: options.reveal, helpContent: options.helpContent }
if (options.copy) {
const field = ((meta.fields ?? {}) as Record<string, FieldMeta>)[options.copy]
if (!field) throw new UsageError(`No field "${options.copy}" in ${name}`)
const record = await getRecord(c, name, primaryKey, false, { tableVariant: variantParam(variant) })
const raw = ((record.values ?? {}) as Json)[options.copy]
const text = raw === null || raw === undefined ? '' : typeof raw === 'object' ? JSON.stringify(raw) : String(raw)
const how = copyToClipboard(text)
c.note(`copied ${field.label} of ${meta.label} ${primaryKey} (${text.length} characters) ${how}`)
return
}
if (options.follow) {
const field = ((meta.fields ?? {}) as Record<string, FieldMeta>)[options.follow]
if (!field) throw new UsageError(`No field "${options.follow}" in ${name}`)
const record = await getRecord(c, name, primaryKey, false, { tableVariant: variantParam(variant) })
const target = relatedRecordTarget(field, record as unknown as RecordLike, Object.keys((await c.metaData()).tables ?? {}))
if (!target) throw new UsageError(`${meta.label} ${primaryKey}: ${field.label} links to no record (empty, or not a link or possible-value field)`)
c.note(`${field.label} → ${target.tableName} ${target.primaryKey}`)
await showRecord(c, target.tableName, target.primaryKey, show)
return
}
await showRecord(c, name, primaryKey, { ...show, query: { queryJoins: plan.joins ? JSON.stringify(plan.joins) : undefined, tableVariant: variantParam(variant) }, joined: plan.used })
})
/** Fills a form session from --set / --input-json, then prompts when asked or when required values are missing on a terminal. */
async function fillForm(c: Context, meta: Json, name: string, given: Json, options: { interactive?: boolean }, initial?: Json, exclude?: string[], cleared: Set<string> = new Set()): Promise<FormSession> {
const fields = Object.values((meta.fields ?? {}) as Record<string, FieldMeta>)
for (const k of Object.keys(given)) if (!(meta.fields as Json)[k]) throw new UsageError(`No field "${k}" in ${name}; fields: ${Object.keys(meta.fields as Json).join(', ')}`)
const session = new FormSession({ baseUrl: c.server, fetch: authenticatedFetch(c.target), scope: { table: name }, fields, initialValues: initial, tableMeta: meta as never, exclude: exclude ?? (initial ? [] : [String(meta.primaryKeyField)]) })
await session.load()
for (const k of session.promptOrder(Object.keys(given))) {
try {
for (const n of (await session.set(k, given[k])).cleared) if (given[n] === undefined) cleared.add(n)
} catch (error) {
if (error instanceof FormInputError) throw new UsageError(error.message)
throw error
}
}
if (options.interactive) await promptForm(session, { skipSet: true })
return session
}
function validationError(session: FormSession, problems: Record<string, string>): never {
throw new UiApiError(400, { code: 'BAD_REQUEST', message: 'Validation failed before sending', fieldErrors: Object.entries(problems).map(([field, message]) => ({ field, message: `${session.fields[field]?.label ?? field} ${message}` })) })
}
/** `--upload field=path` pairs as files, checked against the table's fields. */
function readUploads(meta: Json, pairs: string[] | undefined): Record<string, File> {
const out: Record<string, File> = {}
const fields = (meta.fields ?? {}) as Record<string, Json>
for (const [field, path] of Object.entries(parsePairs(pairs, '--upload'))) {
if (!fields[field]) throw new UsageError(`No field "${field}" in ${String(meta.name)}`)
const bytes = readFileArg(path, `--upload ${field}`)
out[field] = new File([bytes as BlobPart], basename(path))
}
return out
}
/** The multipart record body (the Next UI's recordFormData): text fields as strings, null as an empty value (a clear), files as parts. */
function multipartRecord(values: Json, files: Record<string, File>): Record<string, string | Blob> {
const form: Record<string, string | Blob> = {}
for (const [key, value] of Object.entries(values)) {
if (value === undefined || files[key]) continue
form[key] = value === null ? '' : Array.isArray(value) || typeof value === 'object' ? JSON.stringify(value) : String(value)
}
return { ...form, ...files }
}
table.command('create <table>')
.description('Insert a record from --set name=value pairs or --input-json; -i prompts for the rest. Possible-value fields take a label or an id.')
.option('-s, --set <name=value...>', 'field values')
.option('--input-json <json|file|->', 'the whole record as a JSON object of field values')
.option('-i, --interactive', 'prompt for fields on the terminal')
.option('--generate-input-skeleton', 'print an empty JSON object with the editable fields and stop')
.option('--parent <table=primaryKey>', 'create a child record linked to this parent (through the parent table\'s association)')
.option('--association <name>', 'with --parent: the association, when the parent has several to this table')
.option('-u, --upload <field=path...>', 'a file for a file field (sent as multipart/form-data)')
.option(...VARIANT_OPTION)
.action(async (name: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; generateInputSkeleton?: boolean; parent?: string; association?: string; upload?: string[]; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'insert')
const fields = (meta.fields ?? {}) as Record<string, Json>
if (options.generateInputSkeleton) {
const editable = Object.values(fields).filter((f) => f.isEditable !== false && !f.isHidden && f.name !== meta.primaryKeyField)
c.print({ data: Object.fromEntries(editable.map((f) => [f.name, f.defaultValue ?? ''])) })
c.note(editable.map((f) => `${String(f.name).padEnd(24)} ${f.type}${f.isRequired ? ' required' : ''}${f.possibleValueSourceName ? ` (possible values: ${f.possibleValueSourceName}${Array.isArray(f.possibleValueSourceDependsOn) ? `, depends on ${(f.possibleValueSourceDependsOn as string[]).join(', ')}` : ''})` : ''}`).join('\n'))
return
}
const given: Json = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
const uploads = readUploads(meta, options.upload)
let parentNote = ''
if (options.parent) {
const at = options.parent.indexOf('=')
if (at < 1) throw new UsageError(`--parent takes table=primaryKey: ${options.parent}`)
const parentName = options.parent.slice(0, at)
const parentKey = options.parent.slice(at + 1)
const parentMeta = await c.tableMeta<TableMetaLike>(parentName)
const parent = await getRecord(c, parentName, parentKey, false) as unknown as RecordLike
let link: ReturnType<typeof parentLinkValues>
try {
link = parentLinkValues(parentMeta, parent, meta as unknown as TableMetaLike, options.association)
} catch (error) {
if (error instanceof AssociationError) throw new UsageError(error.message)
throw error
}
for (const [field, value] of Object.entries(link.values)) {
if (given[field] !== undefined && String(given[field]) !== String(value)) throw new UsageError(`--set ${field}=${String(given[field])} contradicts --parent ${options.parent} (${field} is ${String(value)})`)
given[field] = String(value)
}
parentNote = `${parentMeta.label ?? parentName} ${parentKey}${parent.recordLabel ? ` (${parent.recordLabel})` : ''}, association ${link.association.name}`
}
if (!Object.keys(given).length && !Object.keys(uploads).length && !options.interactive) throw new UsageError('Give field values with --set name=value or --input-json, or prompt with -i.')
const variant = await chooseVariant(c, meta, options.variant, { write: true })
const session = await fillForm(c, meta, name, given, options)
const problems = session.validate(Object.keys(uploads).length ? Object.keys(session.fields).filter((n) => !uploads[n]) : undefined)
if (Object.keys(problems).length) validationError(session, problems)
const result = Object.keys(uploads).length
? (await c.call('insertRecord', { path: { tableName: name }, query: { tableVariant: variantParam(variant) }, form: multipartRecord(session.submission(), uploads) })).data as Json
: await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name }, query: { tableVariant: variantParam(variant) } as never }, body: session.submission() as never })) as Json
const record = (result.record ?? result) as Json
const pk = (record.values as Json | undefined)?.[String(meta.primaryKeyField)] ?? record[String(meta.primaryKeyField)] ?? ''
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '', ...(parentNote ? { parent: parentNote } : {}) }], columns: parentNote ? ['created', 'label', 'parent'] : ['created', 'label'] })
})
table.command('copy <table> <primaryKey>')
.description('Copy a record: its values prefill a new record (primary key, non-editable and file fields dropped), --set changes some, and it is inserted as a separate record.')
.option('-s, --set <name=value...>', 'values to change in the copy')
.option('-i, --interactive', 'prompt for every editable field, the copied values shown')
.option('--dry-run', 'print the values the copy would insert and stop')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { set?: string[]; interactive?: boolean; dryRun?: boolean; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
requireTable(meta as unknown as Json, 'insert')
const variant = await chooseVariant(c, meta, options.variant, { write: !options.dryRun })
if (meta.insertPermission === false) throw new UiApiError(403, { code: 'PERMISSION_DENIED', message: `You may not create ${meta.label} records` })
const source = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
const sourceRecord = (source.record ?? source) as Json
const copied = copyRecordValues(meta, sourceRecord)
const given = parsePairs(options.set, '--set')
const session = await fillForm(c, meta, name, given, { interactive: false }, copied, [String(meta.primaryKeyField)])
for (const [field, label] of Object.entries((sourceRecord.displayValues ?? {}) as Json)) if (((meta.fields as Record<string, Json>)[field])?.type !== 'DATE_TIME' && copied[field] !== undefined && given[field] === undefined && session.values[field] === copied[field]) session.displayValues[field] = String(label)
if (options.interactive) await promptForm(session, { skipSet: false })
const problems = session.validate()
if (Object.keys(problems).length) validationError(session, problems)
const values = session.submission()
if (options.dryRun) {
c.print({ data: values, rows: Object.entries(values).map(([field, value]) => ({ field, value: session.displayValues[field] ?? value ?? '' })), columns: ['field', 'value'] })
return
}
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name }, query: { tableVariant: variantParam(variant) } as never }, body: values as never })) as Json
const record = (result.record ?? result) as Json
const pk = (record.values as Json | undefined)?.[String(meta.primaryKeyField)] ?? ''
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '', from: `${meta.label} ${primaryKey}` }], columns: ['created', 'label', 'from'] })
})
table.command('update <table> <primaryKey>')
.description('Update fields of a record from --set name=value pairs or --input-json; -i prompts with the current values.')
.option('-s, --set <name=value...>', 'field values (an empty value clears the field)')
.option('--input-json <json|file|->', 'field values as a JSON object')
.option('-i, --interactive', 'prompt for every editable field, current values shown')
.option('--expected-version <v>', 'optimistic locking: fail when the record changed')
.option('-u, --upload <field=path...>', 'replace a file field with this file (multipart/form-data); --set field= removes the file')
.option('-e, --editor <field...>', 'edit these fields\' current values in $VISUAL / $EDITOR (HTML and code fields as source)')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; expectedVersion?: string; upload?: string[]; editor?: string[]; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'edit')
const variant = await chooseVariant(c, meta, options.variant, { write: true })
const given = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
const uploads = readUploads(meta, options.upload)
if (!Object.keys(given).length && !Object.keys(uploads).length && !options.interactive && !options.editor?.length) throw new UsageError('Give field values with --set name=value or --input-json, -e field for $EDITOR, or prompt with -i.')
// the record's current values: contingent fields need their dependencies (a city needs its
// state), possible-value lookups are narrowed by them, and -i shows them
const existing = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
const initial = (((existing.record ?? existing) as Json).values ?? {}) as Json
for (const field of options.editor ?? []) {
const f = ((meta.fields ?? {}) as Record<string, FieldMeta>)[field]
if (!f) throw new UsageError(`--editor ${field}: not a field of ${meta.label}`)
if (f.isEditable === false) throw new UsageError(`--editor ${field}: ${f.label} cannot be edited`)
if (!process.stdin.isTTY) throw new UsageError('--editor needs a terminal; give the value with --set or --input-json.')
const before = initial[field] === null || initial[field] === undefined ? '' : String(initial[field])
const edited = editText(before, `${name}-${primaryKey}-${field}.${editorExtension(f)}`)
given[field] = before.endsWith('\n') ? edited : edited.replace(/\n$/, '')
}
const cleared = new Set<string>()
const session = await fillForm(c, meta, name, given, { interactive: false }, initial, undefined, cleared)
if (options.interactive) await promptForm(session, { skipSet: false })
// a changed dependency clears its dependents (a new state clears the city): they are sent too
const touched = options.interactive ? session.editable.map((f) => f.name).filter((n) => String(session.values[n] ?? '') !== String(initial[n] ?? '')) : [...Object.keys(given), ...cleared]
if (cleared.size && !options.interactive) c.note(`note: cleared ${[...cleared].map((n) => session.fields[n]?.label ?? n).join(', ')} (depends on a changed field)`)
if (!touched.length && !Object.keys(uploads).length) {
c.note('Nothing changed.')
return
}
const problems = session.validate(touched)
if (Object.keys(problems).length) validationError(session, problems)
const values = Object.fromEntries(touched.map((n) => [n, session.values[n] ?? null]))
const result = Object.keys(uploads).length
? (await c.call('updateRecord', { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion, tableVariant: variantParam(variant) }, form: multipartRecord(values, uploads) })).data as Json
: await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion, tableVariant: variantParam(variant) } as never }, body: values as never })) as Json
const record = (result.record ?? result) as Json
c.print({ data: result, rows: [{ updated: `${meta.label} ${primaryKey}`, label: record.recordLabel ?? '', fields: [...touched, ...Object.keys(uploads)].join(', ') }], columns: ['updated', 'label', 'fields'] })
})
table.command('delete <table> <primaryKey>')
.description('Delete a record (asks first; --yes skips the question).')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'delete')
const variant = await chooseVariant(c, meta, options.variant, { write: true })
if (!await c.confirm(`Delete ${meta.label} ${primaryKey}?`)) {
c.note('Not deleted.')
return
}
const result = await c.unwrap(c.client.DELETE('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
c.print({ data: result ?? {}, rows: [{ deleted: `${meta.label} ${primaryKey}` }], columns: ['deleted'] })
})
table.command('export <table>')
.description('Export matching records to a file (csv, xlsx, json, tsv).')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--case-sensitive', 'text matches (~, CONTAINS, STARTS_WITH, …, --search) match case; by default they ignore it (CASE_INSENSITIVE)')
.option(...ORDER_OPTION)
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('--format <fmt>', 'csv, xlsx, json or tsv (default: from the --out extension, else csv)')
.option('-f, --fields <names>', 'comma-separated fields to include, in column order (default: the server\'s)')
.option('-l, --limit <n>', 'most records')
.option('-o, --out <file>', 'output file, or - for stdout (default: <table>_<date>_<time>.<format> in the download directory)')
.option('--no-header', 'leave out the header row (csv, tsv, xlsx)')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions & { format?: string; out?: string; header: boolean }) => {
const c = ctx()
options.order = orderFrom((text) => c.note(text), options)
const meta = await tableMeta(c, name)
requireTable(meta as unknown as Json, 'read')
const fromOut = options.out && options.out !== '-' && /\.([a-z]+)$/i.exec(options.out)?.[1]?.toLowerCase()
const format = (options.format ?? (fromOut && EXPORT_FORMATS.includes(fromOut) ? fromOut : 'csv')).toLowerCase()
if (!EXPORT_FORMATS.includes(format)) throw new UsageError(`--format must be one of ${EXPORT_FORMATS.join(', ')}`)
const columns = columnsFrom(c, options)
// the export route takes joined columns of exposed joins by name (it has no joins member)
const available = exposedJoins(meta)
const unknown = (columns ?? []).filter((f) => !resolveField(meta, available, f))
if (unknown.length) throw new UsageError(`No field ${unknown.join(', ')} in ${name}; fields: ${Object.keys((meta.fields ?? {}) as Json).join(', ')}`)
const exportLimit = intOption(options.limit, '--limit', 1)
const filter = await resolvedFilterFrom(c, options, meta, columns, available)
const variant = await chooseVariant(c, meta, options.variant)
const result = await c.call('exportTable', { path: { tableName: name }, json: { format, filter, fieldNames: columns, limit: exportLimit, ...(options.header === false ? { includeHeaderRow: false } : {}), ...variantBody(variant) } })
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
const written = writeDownload(options.out, exportFileName(name, format), bytes)
if (written) c.note(`wrote ${written} (${bytes.length} bytes, ${result.contentType})`)
})
// flat aliases, kept from 0.1
program.command('tables', { hidden: true }).description('Alias of `table list`.').option('-a, --app <name>').action(async (options: { app?: string }) => { await table.commands.find((x) => x.name() === 'list')!.parseAsync([...(options.app ? ['--app', options.app] : [])], { from: 'user' }) })
}
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Command } from 'commander'
import { UsageError, type Context } from '../context.js'
/**
* A qqq binary built with the terminal UI (scripts/build-binaries.mjs --with-tui) sets this before the
* CLI starts: it runs the bundled TUI in this process with the given arguments.
*/
declare global {
var __qqqRunTui: ((args: string[]) => Promise<void>) | undefined
}
/** What qqq ui says when neither a bundled TUI nor a source checkout is there. */
export const NO_TUI_MESSAGE = [
'The terminal UI (qqq ui) is not part of this installation: the npm package and the plain qqq binaries carry the CLI only.',
'To get it:',
' - a qqq binary built with the UI (its qqq --version says "with ui"): https://github.com/QRun-IO/qqq-cli/releases',
' - or from source, with Node 20+ and Bun 1.4+ (https://bun.sh):',
' git clone https://github.com/QRun-IO/qqq-cli && cd qqq-cli && npm ci && npm run build',
' node packages/cli/dist/main.js ui',
].join('\n')
/** The TUI entry in a source checkout, next to this package (dev). */
export function tuiEntry(): string {
const here = dirname(fileURLToPath(import.meta.url))
return resolve(join(here, '..', '..', '..', 'tui', 'src', 'main.tsx'))
}
export function registerUi(program: Command, _ctx: () => Context): void {
program.command('ui [route...]')
.description('Open the terminal UI (OpenTUI on Bun). A route opens a screen directly, in the CLI grammar: `qqq ui table query person -w "lastName STARTS_WITH S"`, `qqq ui table get person 3`, `qqq ui process run sleepInteractive`.')
.option('--theme <name>', 'high-contrast (default), classic or light; saved for next time')
.option('--ascii', 'ASCII glyphs instead of box drawing and block characters')
.option('--linear', 'one region at a time, no side pane (as QQQ_UI_LINEAR=1)')
.option('--mouse', 'mouse on for this run (the saved Mouse setting is on by default)')
.option('--no-mouse', 'mouse off for this run: plain terminal text selection')
.option('--check', 'check the terminal and tmux setup, offer fixes, and exit')
.option('--no-check', 'skip the quick terminal check at startup')
.allowUnknownOption(true)
.action(async (route: string[], options: { theme?: string; ascii?: boolean; linear?: boolean; mouse?: boolean; check?: boolean }) => {
const globals = program.opts<{ profile?: string; server?: string }>()
const args: string[] = []
if (globals.profile) args.push('--profile', globals.profile)
if (globals.server) args.push('--server', globals.server)
if (options.theme) args.push('--theme', options.theme)
if (options.ascii) args.push('--ascii')
if (options.linear) args.push('--linear')
if (options.mouse === true) args.push('--mouse')
if (options.mouse === false) args.push('--no-mouse')
if (options.check === true) args.push('--check')
if (options.check === false) args.push('--no-check')
if (route.length) args.push('--', ...route)
if (globalThis.__qqqRunTui) return globalThis.__qqqRunTui(args)
const entry = tuiEntry()
if (!existsSync(entry)) throw new UsageError(NO_TUI_MESSAGE)
const result = spawnSync(process.env.QQQ_BUN ?? 'bun', ['run', entry, ...args], { stdio: 'inherit' })
if (result.error) throw new UsageError(`Cannot start Bun (${result.error.message}); install it (https://bun.sh) or set QQQ_BUN.`)
process.exitCode = result.status ?? 1
})
}
+67
View File
@@ -0,0 +1,67 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { VariantError, readRememberedVariant, rememberVariant, listTableVariants, resolveVariant, usesVariants, variantLabel, type TableVariant } from '@qrun-io/qqq-core'
import { authenticatedFetch } from '../session.js'
import { UsageError, type Context } from '../context.js'
import type { Json } from './shared.js'
/** The --variant option every table command and `process run` take. */
export const VARIANT_OPTION = ['--variant <id|name|type:id>', 'the table variant (tenant, store, …) to use, remembered for the table (- forgets it); see `qqq table variants <table>`'] as const
/**
* The variant a command runs with: the one --variant names, or for a table that uses variants and
* none was named, the only one, a choice on a terminal, or a usage error listing them (the web UI
* asks and queries nothing until one is chosen).
*/
export async function chooseVariant(c: Context, meta: Json, typed: string | undefined, options: { write?: boolean } = {}): Promise<TableVariant | undefined> {
const table = String(meta.name)
if (typed === '-') {
rememberVariant(c.server, table, undefined)
typed = undefined
}
if (typed === undefined && !usesVariants(meta)) return undefined
const label = variantLabel(meta)
// the variant chosen last time for this table (as the web keeps it); --variant changes it, --variant - forgets it
if (typed === undefined) {
const remembered = readRememberedVariant(c.server, table)
if (remembered) {
c.note(`${label}: ${remembered.name ?? remembered.id} (chosen before; --variant to change, --variant - to forget)`)
return remembered
}
}
const variants = await listTableVariants(c.server, authenticatedFetch(c.target), table)
let chosen: TableVariant | undefined
if (typed !== undefined) {
try {
chosen = resolveVariant(variants, typed)
} catch (error) {
if (error instanceof VariantError) throw new UsageError(`--variant: ${error.message}`)
throw error
}
if (!usesVariants(meta)) c.note(`note: ${meta.label} does not use variants; sending ${label} ${chosen.name ?? chosen.id} anyway`)
} else if (variants.length === 1) {
chosen = variants[0]!
c.note(`${label}: ${chosen.name ?? chosen.id} (the only one)`)
} else if (variants.length && process.stdin.isTTY) {
const { ask } = await import('../prompt.js')
variants.forEach((v, i) => process.stderr.write(` ${i + 1}. ${v.name ?? v.id} (${v.id})\n`))
const answer = await ask(`${meta.label} needs a ${label}: `)
if (!answer) throw new UsageError(`No ${label} chosen; nothing was queried.`)
const index = Number(answer)
try {
chosen = Number.isInteger(index) && index >= 1 && index <= variants.length ? variants[index - 1]! : resolveVariant(variants, answer)
} catch (error) {
if (error instanceof VariantError) throw new UsageError(error.message)
throw error
}
} else {
throw new UsageError(`${meta.label} needs a ${label}: --variant <id|name>${variants.length ? ` (${variants.slice(0, 10).map((v) => `${v.name ?? v.id} (${v.id})`).join(', ')}${variants.length > 10 ? ', …' : ''})` : '; the server lists none'}`)
}
if (chosen && usesVariants(meta)) rememberVariant(c.server, table, chosen)
// UI API 1.8.0 reads tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
if (options.write) c.note(`note: the server may ignore the ${label} on inserts, updates and deletes (UI API ${'1.8.0'}); see docs/server-findings.md #19`)
return chosen
}
+147
View File
@@ -0,0 +1,147 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { exportFileName, ReportError, blocksToText, formatCsv, listReports, loadWidget, reportFormat, reportRunPlan, safeFileName, widgetRows } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { authenticatedFetch } from '../session.js'
import { UsageError, type Context } from '../context.js'
import { countFooter, parsePairs, positiveInt, writeDownload, type Json } from './shared.js'
export function registerWidget(program: Command, ctx: () => Context): void {
const widget = program.command('widget').description('Widgets (dashboard tiles): list, get.')
widget.command('list')
.description('List the widgets the application declares.')
.action(async () => {
const c = ctx()
const metaData = await c.metaData() as unknown as Json
const rows = Object.values((metaData.widgets ?? {}) as Record<string, Json>).map((w) => ({ name: w.name, type: w.type, label: w.label ?? '' })).sort((a, b) => String(a.name).localeCompare(String(b.name)))
c.print({ data: rows, columns: ['name', 'type', 'label'] })
})
widget.command('get <widget>')
.description("A widget's data, rendered for the terminal as the web shows it: statistics as lines, tables aligned (typed cells, sub-rows, totals row), charts as bars or a labels-by-datasets table, html as text, parent widgets with their children (each sized in twelfths), data bag and script viewers with their versions; empty states and the server's dropdown message as the web words them.")
.option('-i, --input <name=value...>', 'widget inputs (record id, dropdown selections)')
.option('-r, --record <table=primaryKey>', 'the record the widget sits on (its id and table are sent; cronUI, pivot and filter setups read it)')
.option('--run', 'a process widget: run its process here (with the widget\'s default values)')
.option('--csv [file]', 'the widget\'s table or series as CSV (to a file when given)')
.option('--ascii', 'ASCII bars instead of block characters')
.action(async (name: string, options: { input?: string[]; record?: string; run?: boolean; csv?: string | boolean; ascii?: boolean }) => {
const c = ctx()
const widgets = ((await c.metaData() as unknown as Json).widgets ?? {}) as Record<string, Json>
const params = parsePairs(options.input, '--input')
let record: Json | undefined
if (options.record) {
const [table, pk] = Object.entries(parsePairs([options.record], '--record'))[0]!
const got = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey: pk } } })) as Json
record = (((got.record ?? got) as Json).values ?? {}) as Json
params.id ??= pk
params.tableName ??= table
}
// a widget the metadata does not declare is still asked for (the server decides)
const known = widgets[name] ? widgets : { ...widgets, [name]: { name, label: name } }
const loaded = await loadWidget(c.server, authenticatedFetch(c.target), name, { widgets: known, params, record })
if (options.run) {
const pm = ((loaded.data as Json | undefined)?.processMetaData ?? {}) as Json
if (!pm.name) throw new UsageError(`${name} is not a process widget (or names no process).`)
const defaults = ((loaded.data as Json).defaultValues ?? {}) as Json
const run = program.commands.find((x) => x.name() === 'process')!.commands.find((x) => x.name() === 'run')!
await run.parseAsync([String(pm.name), ...(Object.keys(defaults).length ? ['--values-json', JSON.stringify(defaults)] : [])], { from: 'user' })
return
}
if (options.csv) {
const rows = widgetRows(loaded.blocks)
if (!rows) throw new UsageError(`Widget ${name} has no table or series to export.`)
const csv = formatCsv(rows.rows, rows.columns, rows.headers)
const written = writeDownload(typeof options.csv === 'string' ? options.csv : '-', exportFileName(name, 'csv'), new TextEncoder().encode(csv))
if (written) c.note(`wrote ${written} (${rows.rows.length} rows)`)
return
}
if (c.format === 'json' || c.format === 'yaml') {
c.print({ data: loaded.data ?? { skipped: loaded.skipped } })
return
}
const pm = ((loaded.data as Json | undefined)?.processMetaData ?? {}) as Json
const defaults = ((loaded.data as Json | undefined)?.defaultValues ?? {}) as Json
const hint = loaded.type === 'process' && pm.name ? `\nrun it: qqq process run ${String(pm.name)}${Object.entries(defaults).map(([k, v]) => ` -v ${k}=${String(v)}`).join('')} (or widget get ${name} --run)` : ''
process.stdout.write(blocksToText(loaded.blocks, { width: process.stdout.columns ?? 100, ascii: options.ascii }) + hint + '\n')
if (loaded.error) process.exitCode = 1
})
const report = program.command('report').description('Reports: list, run.')
report.command('list')
.description('The reports you may run, with their table and app.')
.action(async () => {
const c = ctx()
const rows = listReports(await c.metaData() as unknown as Json)
c.print({ data: rows, columns: ['name', 'label', 'table', 'app'], footer: rows.length ? '' : '(no reports in this application)' })
})
report.command('run <report>')
.description('Run a report: through its process (input fields asked, or given with -v; the file comes from the download route) or, for a report without a process, the report route. TABLE, SUMMARY and PIVOT reports come back as the server writes them.')
.option('--format <fmt>', 'csv, xlsx, json or tsv (pdf where the report allows)', 'csv')
.option('-v, --values <name=value...>', 'report input values (report variables)')
.option('-o, --out <file>', 'report route: output file, replaced if it exists, or - for stdout (default: <report>_<date>_<time>.<format> in the download directory)')
.option('--out-dir <dir>', 'report process: where the file is saved (default: the download directory, else the current one)')
.option('--route', 'use the report route even when the report has a process')
.action(async (name: string, options: { format: string; values?: string[]; out?: string; outDir?: string; route?: boolean }) => {
const c = ctx()
const plan = reportRunPlan(await c.metaData() as unknown as Json, name)
if (plan && !plan.hasPermission) throw new UiApiError(403, { code: 'PERMISSION_DENIED', message: `You do not have permission to run ${plan.label}.` })
let format: string
try {
format = reportFormat(options.format)
} catch (error) {
if (error instanceof ReportError) throw new UsageError(error.message)
throw error
}
if (plan?.processName && !options.route) {
if (options.out) throw new UsageError(`${plan.label} runs through its process: use --out-dir (the file keeps the server's name), or --route with --out.`)
const run = program.commands.find((x) => x.name() === 'process')!.commands.find((x) => x.name() === 'run')!
await run.parseAsync([plan.processName, '-v', `reportName=${name}`, '-v', `reportFormat=${format}`, ...(options.values ?? []).flatMap((v) => ['-v', v]), ...(options.outDir ? ['--out-dir', options.outDir] : [])], { from: 'user' })
return
}
// a report without a process (or --route): the streaming report route
const result = await c.call('runReport', { path: { reportName: name }, query: { format: format.toLowerCase(), ...parsePairs(options.values, '--values') } })
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
const written = writeDownload(options.out, exportFileName(name, format.toLowerCase()), bytes)
if (written) c.note(`wrote ${written} (${bytes.length} bytes, ${result.contentType})`)
})
const prefs = program.command('prefs').description('Your preferences: list, get, set, action.')
prefs.command('list').description('The preference sections.').action(async () => {
const c = ctx()
const data = (await c.call('listPreferenceSections', {})).data as Json
const rows = ((data.sections ?? []) as Json[]).map((s) => ({ name: s.name, label: s.label, description: s.description ?? '', fields: ((s.fields ?? []) as Json[]).map((f) => f.name).join(', '), actions: ((s.actions ?? []) as Json[]).map((a) => a.name).join(', ') }))
c.print({ data, rows, columns: ['name', 'label', 'description', 'fields', 'actions'] })
})
prefs.command('get <section>').description('One section with its current values.').action(async (section: string) => {
const c = ctx()
const data = (await c.call('getPreferenceSection', { path: { sectionName: section } })).data as Json
const values = (data.values ?? {}) as Json
const fields = ((data.fields ?? ((data.section as Json | undefined)?.fields) ?? []) as Json[])
const rows = fields.length ? fields.map((f) => ({ field: f.name, label: f.label, value: values[String(f.name)] ?? '' })) : Object.entries(values).map(([field, value]) => ({ field, label: field, value }))
c.print({ data, rows, columns: ['field', 'label', 'value'] })
})
prefs.command('set <section>').description('Change values in a section: --set name=value.').option('-s, --set <name=value...>', 'values').action(async (section: string, options: { set?: string[] }) => {
const c = ctx()
const values = parsePairs(options.set, '--set')
if (!Object.keys(values).length) throw new UsageError('Give values with --set name=value.')
const data = (await c.call('updatePreferenceSection', { path: { sectionName: section }, json: { values } })).data
c.print({ data: data ?? {}, rows: Object.entries(values).map(([field, value]) => ({ updated: field, value })), columns: ['updated', 'value'] })
})
prefs.command('action <section> <action>').description("Run one of a section's actions.").action(async (section: string, action: string) => {
const c = ctx()
const data = (await c.call('runPreferenceSectionAction', { path: { sectionName: section, actionName: action }, json: {} })).data
c.print({ data: data ?? {} })
})
program.command('help-search <text>').description("Search the application's help content.").option('-l, --limit <n>', 'most results', '20').action(async (text: string, options: { limit: string }) => {
const c = ctx()
const data = (await c.call('searchHelp', { query: { q: text, limit: String(positiveInt(options.limit, '--limit')) } })).data as Json
const rows = ((data.results ?? []) as Json[]).map((r) => ({ kind: r.kind, name: r.name, label: r.label ?? '', table: r.table ?? '', href: r.href ?? '' }))
c.print({ data, rows, columns: ['kind', 'name', 'label', 'table', 'href'], footer: countFooter(rows.length, 'result') })
})
}
+252
View File
@@ -0,0 +1,252 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What every command shares: the resolved server, the authenticated client, output rendering with
* the global --output / --query / --fields options, confirmations, and the exit-code policy.
*/
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
try {
stream.write(text)
} catch (error) {
if (isBrokenPipe(error)) process.exit(0)
throw error
}
}
/**
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
* points these at a pane for the duration of one command.
*/
export const io = {
out: (text: string): void => { safeWrite(process.stdout, text) },
err: (text: string): void => { safeWrite(process.stderr, text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
}
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
export function isBrokenPipe(error: unknown): boolean {
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
}
/**
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
* else was going on: the process exits from the stream's error event, from a write that throws, and
* from an uncaught error that is one, so no later code can turn it into a failure.
*/
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
for (const stream of streams) {
stream.on('error', (error) => {
if (isBrokenPipe(error)) exit(0)
else throw error
})
}
on('uncaughtException', (error) => {
if (isBrokenPipe(error)) exit(0)
else {
process.stderr.write(`${error.stack ?? error.message}\n`)
process.exit(1)
}
})
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
export interface GlobalOptions {
server?: string
profile?: string
output?: OutputFormat
json?: boolean
query?: string
fields?: string
yes?: boolean
trace?: boolean
noPager?: boolean
utc?: boolean
}
export class Context {
readonly options: GlobalOptions
private clientCache?: UiApiClient
private registryCache?: Promise<ExtensionRegistry>
constructor(program: Command) {
this.options = program.opts<GlobalOptions>()
if (this.options.output && !OUTPUT_FORMATS.includes(this.options.output)) {
throw new UsageError(`--output must be one of ${OUTPUT_FORMATS.join(', ')}`)
}
selectTarget({ profile: this.options.profile, server: this.options.server })
}
/** The profile and server from --profile, QQQ_PROFILE, --server, QQQ_SERVER or the default profile. */
get target(): Target {
return target()
}
/** The server origin of the target. */
get server(): string {
return this.target.server
}
/** The authenticated typed client. */
get client(): UiApiClient {
this.clientCache ??= clientFor(this.target)
return this.clientCache
}
/** The output format in effect. */
get format(): OutputFormat {
if (this.options.output) return this.options.output
if (this.options.json) return 'json'
return defaultFormat(io.isTTY())
}
/** How DATE_TIME values are shown: in the TZ zone, or raw UTC instants with --utc. */
get dateTimeDisplay(): DisplayOptions {
return { utc: Boolean(this.options.utc), timeZone: defaultTimeZone() }
}
/** `--fields a,b,c` as a list, or undefined. */
get fields(): string[] | undefined {
return this.options.fields ? this.options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined
}
/** Prints a result in the chosen format, after --query. */
print(printable: Printable): void {
io.out(render(printable, this.format, this.options.query, { width: io.width() }) + '\n')
}
/** Reshapes data with --query without printing (for callers that print themselves). */
shape<T>(data: T): unknown {
return applyQuery(data, this.options.query)
}
/** The server's metadata, from the on-disk cache when fresh. */
metaData(options: { refresh?: boolean } = {}): Promise<MetaDataDocument> {
return getMetaData(this.server, authenticatedFetch(this.target), options)
}
/** A table's definition, cached for five minutes (fields, sections, capabilities). */
tableMeta<T = Record<string, unknown>>(table: string, options: { refresh?: boolean } = {}): Promise<T> {
return getTableMetaData<T>(this.server, table, authenticatedFetch(this.target), options)
}
/** The server's extensions (GET /version, cached with the metadata). */
extensions(options: { refresh?: boolean } = {}): Promise<ExtensionRegistry> {
// QQQ_EXTENSION_CHECK=off treats every extension the client knows as installed, for mock
// servers (Prism) whose GET /version is only the document's example.
if (process.env.QQQ_EXTENSION_CHECK === 'off') return Promise.resolve(new ExtensionRegistry(KNOWN_EXTENSIONS.map((e) => ({ name: e.name, version: e.documentVersion, pathPrefix: e.pathPrefix, known: true, meetsMinimum: true }))))
this.registryCache ??= extensionRegistry(this.server, authenticatedFetch(this.target), options)
return this.registryCache
}
/** The collaboration module for this server, with the realtime stream for presence. */
async collaboration(): Promise<Collaboration> {
const t = this.target
return new Collaboration(await this.extensions(), {
call: (id, input) => this.call(id, input),
openStream: (signal) => (async function* () {
const session = await sessionCookieHeaderFor(t)
yield* openRealtimeStream({
baseUrl: t.server,
clientChannel: CLIENT_CHANNEL,
signal,
headers: session ? { Cookie: session } : undefined,
token: session ? undefined : () => currentToken(t),
})
})(),
})
}
/** The assistant module for this server: the feature probe and streamed questions. */
async assistant(): Promise<Assistant> {
const t = this.target
return new Assistant(await this.extensions(), { call: (id, input) => this.call(id, input), fetch: authenticatedFetch(t), baseUrl: t.server })
}
/** The printing module for this server: printers, jobs and Print through the qbit's tables and processes. */
async printing(): Promise<Printing> {
const t = this.target
return new Printing(await this.extensions(), {
call: (id, input) => this.call(id, input),
runProcess: async (processName, { values, recordIds }) => {
const meta = await this.unwrap(this.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as unknown as ProcessMeta
const outcome = await runProcess({ baseUrl: t.server, fetch: authenticatedFetch(t), meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })
return outcome.values
},
})
}
/** Calls an operation by id (the generic escape hatch). */
call(id: string, input: CallInput): Promise<CallResult> {
return callOperation(this.server, id, input, authenticatedFetch(this.target))
}
/** Unwraps a typed client call. */
unwrap<T>(result: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
return unwrap(result)
}
/**
* Asks on the terminal unless --yes; without a terminal and without --yes, refuses. Ctrl-C at the
* question exits 130 (prompt.ask passes it on as SIGINT), never a silent "no".
*/
async confirm(question: string): Promise<boolean> {
if (this.options.yes) return true
if (!process.stdin.isTTY) throw new UsageError(`${question} Pass --yes to confirm without a terminal.`)
const { ask } = await import('./prompt.js')
return /^y(es)?$/i.test(await ask(`${question} [y/N] `))
}
/** A note for people, on stderr so pipes stay clean. */
note(text: string): void {
io.err(text + '\n')
}
}
/** A mistake in how the command was called (exit 2). */
export class UsageError extends Error {}
/** Maps an error to its exit code and the stderr text. */
export function describeError(error: unknown, trace = false): { code: number; text: string } {
if (error instanceof UsageError || error instanceof FilterSyntaxError || error instanceof UnsafeFileNameError || error instanceof OperationInputError) return { code: EXIT.usage, text: `Error: ${error.message}` }
if (error instanceof ExtensionNotInstalledError) return { code: EXIT.notFound, text: `Error: ${error.message}` }
if (error instanceof UiApiError) {
// a server failure that only says a QBit is missing (attachments without Object Storage): one plain line
const missing = missingQBitMessage(error)
if (missing) return { code: EXIT.unexpected, text: `Error: ${missing}` }
const lines = [`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`]
for (const fieldError of error.fieldErrors) lines.push(` ${fieldError.field}: ${fieldError.message}`)
if (error.code === 'UNAUTHENTICATED') lines.push('Sign in again with `qqq login`.')
const code = error.code === 'UNAUTHENTICATED' || error.code === 'PERMISSION_DENIED' ? EXIT.auth
: error.code === 'RATE_LIMITED' ? EXIT.transient
: error.code === 'NOT_FOUND' ? EXIT.notFound
: error.code === 'BAD_REQUEST' || error.code === 'CONFLICT' ? EXIT.validation
// a gateway or an overloaded server: try again later, as a refused connection
: error.status === 502 || error.status === 503 || error.status === 504 ? EXIT.transient
: error.status >= 500 ? EXIT.unexpected : EXIT.error
return { code, text: lines.join('\n') }
}
if (error instanceof ProcessError) return { code: error.isInputError ? EXIT.validation : EXIT.error, text: `Error: ${error.message}` }
if (error instanceof TypeError && /fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|network|bad port/i.test(`${error.message} ${String((error as Error & { cause?: unknown }).cause ?? '')}`)) {
return { code: EXIT.transient, text: `Error: cannot reach the server (${String((error as Error & { cause?: { message?: string } }).cause?.message ?? error.message)})` }
}
const message = error instanceof Error ? error.message : String(error)
return { code: EXIT.error, text: trace && error instanceof Error && error.stack ? error.stack : `Error: ${message}` }
}
-78
View File
@@ -1,78 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { components } from "@qrun-io/qqq-ui-api-client"
type Json = Record<string, unknown>
type Operator = NonNullable<components["schemas"]["FilterCriteria"]["operator"]>
const OPERATORS = new Set([
'EQUALS', 'NOT_EQUALS', 'NOT_EQUALS_OR_IS_NULL', 'IN', 'NOT_IN', 'IS_NULL_OR_IN', 'LIKE', 'NOT_LIKE', 'STARTS_WITH', 'ENDS_WITH',
'CONTAINS', 'CONTAINS_ALL', 'NOT_STARTS_WITH', 'NOT_ENDS_WITH', 'NOT_CONTAINS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN',
'GREATER_THAN_OR_EQUALS', 'IS_BLANK', 'IS_NOT_BLANK', 'BETWEEN', 'NOT_BETWEEN', 'TRUE', 'FALSE',
])
const SHORTHAND: Record<string, string> = {
'=': 'EQUALS', '==': 'EQUALS', '!=': 'NOT_EQUALS', '<': 'LESS_THAN', '<=': 'LESS_THAN_OR_EQUALS', '>': 'GREATER_THAN',
'>=': 'GREATER_THAN_OR_EQUALS', '~': 'CONTAINS',
}
/** A filter criterion of the UI API (FilterCriteria). */
export interface Criterion {
fieldName: string
operator: Operator
values: string[]
}
/**
* Parses `field OPERATOR value[,value]` (operators as in FilterCriteria, or =, !=, <, <=, >, >=, ~).
*
* @param text - The criterion.
* @param fields - The table's fields (to check the name).
*/
export function parseCriteria(text: string, fields: Record<string, unknown>): Criterion {
const match = /^\s*(\S+)\s+(\S+)\s*(.*)$/.exec(text)
if (!match) throw new Error(`Criteria must be "field OPERATOR value": ${text}`)
const [, fieldName, rawOperator, rest] = match
const operator = SHORTHAND[rawOperator] ?? rawOperator.toUpperCase()
if (!OPERATORS.has(operator)) throw new Error(`Unknown operator ${rawOperator}; use one of ${[...OPERATORS].join(', ')}`)
if (Object.keys(fields).length && !(fieldName in fields)) throw new Error(`No field ${fieldName}; fields: ${Object.keys(fields).join(', ')}`)
const values = rest === '' ? [] : ['IN', 'NOT_IN', 'IS_NULL_OR_IN', 'BETWEEN', 'NOT_BETWEEN', 'CONTAINS_ALL'].includes(operator) ? rest.split(',').map((v) => v.trim()) : [rest.trim()]
return { fieldName, operator: operator as Operator, values }
}
/** Parses `name=value` pairs. */
export function parseValues(pairs: string[]): Record<string, string> {
const values: Record<string, string> = {}
for (const pair of pairs) {
const at = pair.indexOf('=')
if (at < 1) throw new Error(`Values are name=value: ${pair}`)
values[pair.slice(0, at)] = pair.slice(at + 1)
}
return values
}
/** A record's value as text: its display value when the server sent one. */
export function valueText(record: Json, field: string): string {
const display = (record.displayValues as Json | undefined)?.[field]
const raw = (record.values as Json | undefined)?.[field]
const value = display ?? raw
if (value === null || value === undefined) return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/**
* Formats rows as an aligned text table.
*
* @param rows - Objects.
* @param columns - Keys to show.
* @param headers - Optional header labels.
*/
export function formatTable(rows: Array<Record<string, unknown>>, columns: string[], headers: string[] = columns): string {
const cell = (v: unknown) => (v === null || v === undefined ? '' : String(v)).replace(/\s+/g, ' ').slice(0, 60)
const widths = columns.map((c, i) => Math.max(headers[i].length, ...rows.map((r) => cell(r[c]).length)))
const line = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd()
return [line(headers), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(columns.map((c) => cell(r[c]))))].join('\n')
}
+28 -266
View File
@@ -5,274 +5,36 @@
*/
/**
* qqq - a reference command-line client for QQQ applications. Every call is an operation of the
* QQQ UI API (/qqq/v1), through the generated @qrun-io/qqq-ui-api-client; nothing is hard-coded
* about an application: apps, tables, fields and processes all come from metadata.
* qqq - the command-line client for QQQ applications. Every call is an operation of the QQQ UI API
* (/qqq/v1); nothing is hard-coded about an application: apps, tables, fields and processes all
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, widget, report, prefs, api, and one noun per
* extension: esb, collab, sql-workbench, ...), plus the flat
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
*/
import { Command } from 'commander'
import {
UiApiError, decodeJwtClaims, deviceSignIn, openRealtimeStream, unwrap, UI_API_VERSION,
} from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, CLI_VERSION, clientFor, currentToken, getLogin, nativeConfig, removeLogin, resolveServer, saveLogin } from './session.js'
import { formatTable, parseCriteria, parseValues, valueText } from './format.js'
import { CommanderError } from 'commander'
import { EXIT, describeError, exitOnBrokenPipe, isBrokenPipe } from './context.js'
import { createProgram } from './program.js'
import { importLegacyCredentials } from './session.js'
import { recordInvocation } from './commands/history.js'
type Json = Record<string, unknown>
const program = new Command()
program
.name('qqq')
.description('Reference CLI for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`)
.option('-s, --server <url>', 'the QQQ server (default: QQQ_SERVER, or the last one signed in to)')
.option('--json', 'print JSON instead of tables')
function server(): string {
return resolveServer(program.opts().server)
}
function json(): boolean {
return Boolean(program.opts().json)
}
function print(data: unknown, table?: () => string): void {
console.log(json() || !table ? JSON.stringify(data, null, 2) : table())
}
program.command('login')
.description('Sign in with the device-authorization grant (approve the code in any browser).')
.action(async () => {
const origin = server()
const config = await nativeConfig(origin)
const tokens = await deviceSignIn(config, (prompt) => {
console.error(`To sign in to ${origin}, open:\n\n ${prompt.verificationUriComplete ?? prompt.verificationUri}\n`)
console.error(`and confirm the code ${prompt.userCode} (it expires in ${Math.round(prompt.expiresIn / 60)} minutes).`)
console.error('Waiting for approval...')
})
saveLogin(origin, { config, tokens })
const claims = decodeJwtClaims(tokens.accessToken)
console.error(`Signed in to ${origin} as ${claims.email ?? claims.preferred_username ?? claims.sub}.`)
})
program.command('logout')
.description('Forget this server\'s tokens.')
.action(() => {
const origin = server()
console.error(removeLogin(origin) ? `Signed out of ${origin}.` : `Not signed in to ${origin}.`)
})
program.command('whoami')
.description('Show the signed-in user, the server version and its extensions.')
.action(async () => {
const origin = server()
const client = clientFor(origin)
const version = await unwrap(client.GET('/qqq/v1/version')) as Json
const token = await currentToken(origin)
const claims = token && token.includes('.') ? decodeJwtClaims(token) : {}
const metaData = await unwrap(client.GET('/qqq/v1/metaData', {})) as Json
const login = getLogin(origin)
const result = {
server: origin,
user: claims.email ?? claims.preferred_username ?? claims.sub,
name: claims.name,
tokenExpires: login ? new Date(login.tokens.expiresAt).toISOString() : undefined,
apiVersion: version.apiVersion,
extensions: (version.extensions as Json[] | undefined)?.map((e) => `${e.name} ${e.version}`),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
}
print(result, () => Object.entries(result).map(([k, v]) => `${k.padEnd(13)} ${Array.isArray(v) ? v.join(', ') : v ?? ''}`).join('\n'))
})
program.command('apps')
.description('List the apps you may use.')
.action(async () => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
const apps = Object.values((metaData.apps ?? {}) as Record<string, Json>)
.map((a) => ({ name: a.name, label: a.label, items: (a.children as unknown[] | undefined)?.length ?? 0 }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(apps, () => formatTable(apps, ['name', 'label', 'items']))
})
program.command('tables')
.description('List the tables you may read (optionally of one app).')
.option('-a, --app <name>', 'only tables in this app')
.action(async (options: { app?: string }) => {
const metaData = await unwrap(clientFor(server()).GET('/qqq/v1/metaData', {})) as Json
let tables = Object.values((metaData.tables ?? {}) as Record<string, Json>).filter((t) => !t.isHidden && t.readPermission !== false)
if (options.app) {
const app = (metaData.apps as Record<string, Json> | undefined)?.[options.app]
if (!app) throw new Error(`No app named ${options.app} (see \`qqq apps\`)`)
const names = new Set<unknown>()
const visit = (node: Json | undefined, seen: Set<unknown>) => {
for (const child of (node?.children ?? []) as Json[]) {
if (child.type === 'TABLE') names.add(child.name)
else if (child.type === 'APP' && !seen.has(child.name)) visit((metaData.apps as Record<string, Json>)[String(child.name)], seen.add(child.name))
}
}
visit(app, new Set([options.app]))
tables = tables.filter((t) => names.has(t.name))
}
const rows = tables.map((t) => ({ name: t.name, label: t.label, insert: t.insertPermission ? 'yes' : '', edit: t.editPermission ? 'yes' : '', delete: t.deletePermission ? 'yes' : '' }))
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
print(rows, () => formatTable(rows, ['name', 'label', 'insert', 'edit', 'delete']))
})
program.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]", e.g. "status EQUALS active" or "id GREATER_THAN 10".')
.option('-w, --where <criteria...>', 'criteria (ANDed)')
.option('--or', 'OR the criteria instead')
.option('-o, --order <field>', 'order by a field (prefix with - for descending)')
.option('-l, --limit <n>', 'most records to return', '25')
.option('-f, --fields <names>', 'comma-separated fields to show (default: the first few)')
.action(async (table: string, options: { where?: string[]; or?: boolean; order?: string; limit: string; fields?: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const criteria = (options.where ?? []).map((text) => parseCriteria(text, fieldMeta))
const order = options.order ? [{ fieldName: options.order.replace(/^-/, ''), isAscending: !options.order.startsWith('-') }] : undefined
const body = { filter: { criteria, booleanOperator: options.or ? 'OR' as const : 'AND' as const, orderBys: order, limit: Number(options.limit) } }
const result = await unwrap(client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: table } }, body })) as Json
const records = (result.records ?? []) as Json[]
const fields = options.fields ? options.fields.split(',') : defaultFields(meta)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)])))
print(json() ? records : rows, () => `${formatTable(rows, fields, fields.map((f) => String(fieldMeta[f]?.label ?? f)))}\n(${records.length} record${records.length === 1 ? '' : 's'})`)
})
program.command('get <table> <primaryKey>')
.description('View one record, with field labels and display values.')
.action(async (table: string, primaryKey: string) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/table/{tableName}', { params: { path: { tableName: table } } })) as Json
const result = await unwrap(client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey } } })) as Json
const record = (result.record ?? result) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const rows = Object.keys(fieldMeta).filter((f) => !fieldMeta[f].isHidden)
.map((f) => ({ field: String(fieldMeta[f].label ?? f), value: valueText(record, f) }))
print(record, () => `${record.recordLabel ?? `${meta.label} ${primaryKey}`}\n${formatTable(rows, ['field', 'value'], ['Field', 'Value'])}`)
})
program.command('run <process>')
.description('Run a process: init, then each step until it completes; prints its results.')
.option('-v, --value <name=value...>', 'process input values')
.option('-r, --records <ids>', 'comma-separated primary keys of the process table\'s records to run on')
.option('--timeout <ms>', 'how long each step may run synchronously', '60000')
.action(async (processName: string, options: { value?: string[]; records?: string; timeout: string }) => {
const client = clientFor(server())
const meta = await unwrap(client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName } } })) as Json
const steps = ((meta.frontendSteps ?? []) as Json[]).map((s) => String(s.name))
const values = parseValues(options.value ?? [])
const form = (extra: Record<string, string>) => {
const data = new FormData()
data.append('values', JSON.stringify(values))
data.append('stepTimeoutMillis', options.timeout)
for (const [k, v] of Object.entries(extra)) data.append(k, v)
return data
}
const records: Record<string, string> = options.records ? { recordsParam: 'recordIds', recordIds: options.records } : {}
let response = await unwrap(client.POST('/qqq/v1/processes/{processName}/init', {
params: { path: { processName } }, body: {} as never, bodySerializer: () => form(records),
})) as Json
console.error(`${meta.label ?? processName}: started (${response.processUUID})`)
for (let guard = 0; guard < 50; guard++) {
response = await waitForJob(client, processName, response)
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error))
const next = response.nextStep as string | undefined
if (!next) break
const isLast = steps.indexOf(next) === steps.length - 1
console.error(` step ${next}${isLast ? ' (results)' : ''}`)
if (isLast) break
response = await unwrap(client.POST('/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}', {
params: { path: { processName, processUUID: String(response.processUUID), stepName: next } }, body: {} as never, bodySerializer: () => form({}),
})) as Json
}
const out = (response.values ?? {}) as Json
const summary = (out.processResults ?? out.recordListSummary ?? []) as Json[]
if (json()) return print(response)
if (Array.isArray(summary) && summary.length) {
for (const line of summary) console.log(`[${line.status ?? 'INFO'}] ${line.count ?? ''} ${line.message ?? ''}`.replace(/\s+/g, ' ').trim())
} else {
const shown = Object.entries(out).filter(([, v]) => v !== null && typeof v !== 'object').slice(0, 20)
console.log(shown.length ? formatTable(shown.map(([k, v]) => ({ value: k, result: String(v) })), ['value', 'result']) : 'Done.')
}
})
program.command('jobs')
.description('List your jobs (durable and tracked processes).')
.option('--all', 'all states (default: active and recent)')
.action(async (options: { all?: boolean }) => {
let result: Json
try {
result = await unwrap(clientFor(server()).GET('/qqq/v1/jobs', { params: { query: { scope: 'mine', ...(options.all ? {} : { state: 'active' }) } as never } })) as Json
} catch (error) {
if (error instanceof UiApiError && error.status === 404) throw new Error('This server has no jobs operations (durable jobs are not enabled on it).')
throw error
}
const jobs = ((result.jobs ?? []) as Json[]).map((j) => ({ id: j.jobId, label: j.label ?? j.processName, status: j.status, percent: j.percent ?? '', message: j.message ?? '' }))
print(result, () => `${formatTable(jobs, ['id', 'label', 'status', 'percent', 'message'])}\n(${result.activeCount ?? 0} active)`)
})
program.command('tail')
.description('Follow your jobs and notifications live (realtime stream, channel user:me).')
.option('-c, --channel <channels...>', 'more channels, e.g. table:person or record:person:42')
.action(async (options: { channel?: string[] }) => {
const origin = server()
const client = clientFor(origin)
const controller = new AbortController()
process.on('SIGINT', () => controller.abort())
for await (const message of openRealtimeStream({ baseUrl: origin, token: () => currentToken(origin), clientChannel: CLIENT_CHANNEL, signal: controller.signal })) {
const data = (message.data ?? {}) as Json
if (message.event === 'hello') {
const connectionId = String(data.connectionId ?? (data.data as Json | undefined)?.connectionId ?? '')
const add = ['user:me', ...(options.channel ?? [])]
await unwrap(client.POST('/qqq/v1/realtime/subscriptions', { body: { connectionId, add } }))
console.error(`Following ${add.join(', ')} on ${origin} (Ctrl-C to stop).`)
continue
}
if (json()) {
console.log(JSON.stringify(message))
continue
}
const payload = (data.data ?? data) as Json
const time = new Date().toISOString().slice(11, 19)
const detail = payload.label ?? payload.title ?? payload.message ?? payload.jobId ?? ''
const progress = payload.percent !== undefined && payload.percent !== null ? ` ${payload.percent}%` : ''
console.log(`${time} ${message.event}${data.channel ? ` [${data.channel}]` : ''} ${detail}${progress}${payload.status ? ` ${payload.status}` : ''}`.trim())
}
})
/** The fields a query shows by default: the primary key and the next few visible fields. */
function defaultFields(meta: Json): string[] {
const fields = Object.values((meta.fields ?? {}) as Record<string, Json>).filter((f) => !f.isHidden && !f.isHeavy).map((f) => String(f.name))
const pk = String(meta.primaryKeyField ?? 'id')
return [pk, ...fields.filter((f) => f !== pk)].slice(0, 6)
}
/** Polls an asynchronous step (JOB_STARTED / RUNNING) until it completes. */
async function waitForJob(client: ReturnType<typeof clientFor>, processName: string, response: Json): Promise<Json> {
let current = response
while (current.type === 'JOB_STARTED' || current.type === 'RUNNING') {
const jobUUID = String(current.jobUUID ?? response.jobUUID)
await new Promise((resolve) => setTimeout(resolve, 1000))
current = await unwrap(client.GET('/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}', {
params: { path: { processName, processUUID: String(response.processUUID), jobUUID } },
})) as Json
if (current.type === 'RUNNING') console.error(` running ${current.current ?? ''}/${current.total ?? ''} ${current.message ?? ''}`)
current = { ...current, jobUUID, processUUID: response.processUUID }
exitOnBrokenPipe()
const program = createProgram()
// every invocation goes to the activity log (`qqq history`) once, on exit: also when a command
// ends the process itself (the TUI, Ctrl-C at a prompt)
const startedAt = Date.now()
process.once('exit', (code) => {
const opts = program.opts<{ profile?: string; server?: string }>()
recordInvocation(process.argv.slice(2), { profile: opts.profile, server: opts.server }, typeof process.exitCode === 'number' ? process.exitCode : code, startedAt)
})
importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown) => {
if (error instanceof CommanderError) {
process.exitCode = error.exitCode === 0 ? 0 : EXIT.usage
return
}
return current
}
program.parseAsync().catch((error: unknown) => {
if (error instanceof UiApiError) {
console.error(`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`)
for (const fieldError of error.fieldErrors) console.error(` ${fieldError.field}: ${fieldError.message}`)
if (error.code === 'UNAUTHENTICATED') console.error('Sign in again with `qqq login`.')
} else {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
process.exitCode = 1
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
if (isBrokenPipe(error)) process.exit(0)
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
})
+157
View File
@@ -0,0 +1,157 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The qqq program as a library: `createProgram()` builds the commander program with every command,
* and `runCli(argv, { stdout, stderr })` runs one command line in-process with its output sent to
* the given writers. The TUI's `:` command line uses runCli, so the CLI grammar is the TUI's URL
* scheme and both heads share one dispatcher.
*/
import { Command, CommanderError } from 'commander'
import { Context, EXIT, describeError, io } from './context.js'
import { versionText } from './version.js'
export { buildDescription } from './version.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
import { registerBulk } from './commands/bulk.js'
import { registerReports } from './commands/reports.js'
import { registerCompletion } from './commands/completion.js'
import { registerConfig, registerProfileCommands } from './commands/config.js'
import { registerDeveloper } from './commands/developer.js'
import { registerDoctor } from './commands/doctor.js'
import { registerExtensions } from './commands/extensions.js'
import { registerHelp } from './commands/help.js'
import { registerHistory } from './commands/history.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
import { registerPrint } from './commands/print.js'
import { registerProcess } from './commands/process.js'
import { registerSavedView } from './commands/saved-view.js'
import { registerRelated } from './commands/related.js'
import { registerTable } from './commands/table.js'
import { registerUi } from './commands/ui.js'
import { registerWidget } from './commands/widget.js'
/** Builds a fresh program (a new Context per run). */
export function createProgram(): Command {
const program = new Command()
program
.name('qqq')
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(versionText(), '--version', 'print the version')
.option('--server <url>', 'the QQQ server (default: QQQ_SERVER, or the default profile)')
.option('-p, --profile <name>', 'the profile to use (default: QQQ_PROFILE, or the default profile)')
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
.option('--json', 'same as --output json')
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
.option('--trace', 'print stack traces for unexpected errors')
.exitOverride()
.configureOutput({ writeOut: (text) => io.out(text), writeErr: (text) => io.err(text) })
let context: Context | undefined
const ctx = () => (context ??= new Context(program))
registerAuth(program, ctx)
registerMisc(program, ctx)
registerTable(program, ctx)
registerDeveloper(program, ctx)
registerAudit(program, ctx)
registerRelated(program, ctx)
registerBulk(program, ctx)
registerProcess(program, ctx)
registerPrint(program, ctx)
registerSavedView(program, ctx)
registerJob(program, ctx)
registerWidget(program, ctx)
registerReports(program, ctx)
registerApi(program, ctx)
registerExtensions(program, ctx)
registerCompletion(program, ctx)
registerConfig(program, ctx)
registerProfileCommands(program.command('profile').description('Named servers: list, add, edit, use, remove (the TUI\'s Servers dialog, Ctrl+O).'), ctx)
registerDoctor(program, ctx)
registerHelp(program, ctx)
registerHistory(program, ctx)
registerUi(program, ctx)
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
function alias(flat: string, noun: string, verb: string, description: string): void {
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
for (const option of target.options) cmd.addOption(option)
cmd.action(async (...args: unknown[]) => {
const commandArgs = args.slice(0, -2) as string[]
const options = args[args.length - 2] as Record<string, unknown>
const argv: string[] = [...commandArgs]
for (const option of target.options) {
const value = options[option.attributeName()]
if (value === undefined || value === false) continue
// a short-only option (the deprecated -o sort) has no long form
const flag = (option.long ?? option.short)!
if (value === true) argv.push(flag)
else if (Array.isArray(value)) for (const v of value) argv.push(flag, String(v))
else argv.push(flag, String(value))
}
await target.parseAsync(argv, { from: 'user' })
})
}
alias('query', 'table', 'query', 'Query a table')
alias('get', 'table', 'get', 'View one record')
alias('run', 'process', 'run', 'Run a process')
return program
}
export interface RunCliIo {
stdout: (text: string) => void
stderr: (text: string) => void
/** Whether the output may use terminal formatting (default false: table output is still chosen by --output). */
isTTY?: boolean
/** The width nested results are fitted to in table output (default: none). */
width?: number
}
/**
* Runs one command line in-process (argv without the program name) and returns its exit code.
* Output written by commands goes to the writers; prompts are off (no terminal), so commands that
* would ask need their values or --yes. One run at a time.
*/
export async function runCli(argv: string[], options: RunCliIo): Promise<number> {
const saved = { out: io.out, err: io.err, isTTY: io.isTTY, width: io.width, stdoutWrite: process.stdout.write, stderrWrite: process.stderr.write, stdinTTY: Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') }
io.out = options.stdout
io.err = options.stderr
io.isTTY = () => options.isTTY ?? false
io.width = () => options.width
// a few commands write to the process streams directly; capture those too while the command runs
process.stdout.write = ((chunk: string | Uint8Array) => { options.stdout(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stdout.write
process.stderr.write = ((chunk: string | Uint8Array) => { options.stderr(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stderr.write
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true })
const program = createProgram()
try {
await program.parseAsync(argv, { from: 'user' })
return typeof process.exitCode === 'number' ? process.exitCode : 0
} catch (error) {
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : EXIT.usage
const { code, text } = describeError(error, Boolean(program.opts().trace))
options.stderr(text + '\n')
return code
} finally {
process.exitCode = undefined
io.out = saved.out
io.err = saved.err
io.isTTY = saved.isTTY
io.width = saved.width
process.stdout.write = saved.stdoutWrite
process.stderr.write = saved.stderrWrite
if (saved.stdinTTY) Object.defineProperty(process.stdin, 'isTTY', saved.stdinTTY)
else delete (process.stdin as { isTTY?: boolean }).isTTY
}
}
+172
View File
@@ -0,0 +1,172 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Terminal prompts for filling a FormSession: one question per field, choices listed for
* possible-value fields (contingent ones only once their dependencies are set), local validation
* before moving on. Used by `table create -i`, `table update -i` and `process run` on a terminal.
*/
import { createInterface, type Interface } from 'node:readline/promises'
import { ChoiceError, isMissingValue, type FieldMeta, type FormSession } from '@qrun-io/qqq-core'
import { UsageError } from './context.js'
export interface PromptOptions {
/** Ask only for these fields (default: every editable field). */
only?: string[]
/** Skip fields that already have a value. */
skipSet?: boolean
/** Where prompts are written (default stderr, so stdout stays machine-readable). */
out?: NodeJS.WritableStream
/** Aborting stops the prompt (the question rejects with an AbortError). */
signal?: AbortSignal
}
/**
* A readline interface whose Ctrl-C is passed on as SIGINT to the process (readline takes the
* terminal into raw mode, so the key would otherwise only reach the interface, which closes and
* fails the question with a bare AbortError): a command that listens for SIGINT (process run
* cancels its run) gets it, anything else exits 130 as a shell expects.
*/
function openInterface(out: NodeJS.WritableStream, input: NodeJS.ReadableStream = process.stdin): Interface {
const rl = createInterface({ input, output: out, terminal: Boolean((input as { isTTY?: boolean }).isTTY) })
rl.on('SIGINT', () => {
if (process.listenerCount('SIGINT') > 0) process.emit('SIGINT', 'SIGINT')
else process.exit(130)
})
return rl
}
/**
* Ctrl-D (end of input) at a question is a usage error; an abort the caller asked for stays an abort.
* The interface closing is watched too: some Node versions (22.12) leave the pending question
* unsettled when Ctrl-D closes the interface, which would otherwise hang the command.
*/
async function question(rl: Interface, text: string, signal?: AbortSignal): Promise<string> {
let onClose = () => {}
const closed = new Promise<never>((_, reject) => {
onClose = () => reject(signal?.aborted ? signal.reason : Object.assign(new Error('The interface was closed.'), { code: 'ABORT_ERR' }))
rl.once('close', onClose)
})
closed.catch(() => {})
try {
return await Promise.race([rl.question(text, { signal }), closed])
} catch (error) {
if (!signal?.aborted && (error as { code?: string }).code === 'ABORT_ERR') throw new UsageError('No answer (end of input).')
throw error
} finally {
rl.off('close', onClose)
}
}
/** Asks one question on the terminal; returns the trimmed answer. */
export async function ask(text: string, options: { out?: NodeJS.WritableStream; signal?: AbortSignal; input?: NodeJS.ReadableStream } = {}): Promise<string> {
const input = options.input ?? process.stdin
if (!(input as { isTTY?: boolean }).isTTY) throw new UsageError('This question needs a terminal.')
const rl = openInterface(options.out ?? process.stderr, input)
try {
return (await question(rl, text, options.signal)).trim()
} finally {
rl.close()
}
}
/** Asks for each field in turn and sets it on the session; Ctrl-D or an empty required answer re-asks. */
export async function promptForm(session: FormSession, options: PromptOptions = {}): Promise<void> {
if (!process.stdin.isTTY) throw new UsageError('Interactive prompts need a terminal; give values with --set, --values or --input-json.')
const out = options.out ?? process.stderr
const rl = openInterface(out)
try {
for (const name of session.promptOrder(options.only)) {
const field = session.fields[name]!
if (field.isEditable === false || field.isHidden) continue
if (options.skipSet && !isMissingValue(session.values[field.name])) continue
await promptField(rl, out, session, field, options.signal)
}
} finally {
rl.close()
}
}
async function promptField(rl: Interface, out: NodeJS.WritableStream, session: FormSession, field: FieldMeta, signal?: AbortSignal): Promise<void> {
for (;;) {
const state = session.state(field.name)
if (state.disabledBecause) {
out.write(` ${field.label}: skipped (${state.disabledBecause})\n`)
return
}
const current = state.displayValue ?? (isMissingValue(state.value) ? '' : String(state.value))
let hint = field.type === 'DATE_TIME' ? `local date time YYYY-MM-DDTHH:MM, ${session.timeZone}` : field.type.toLowerCase().replace('_', ' ')
if (session.hasChoices(field.name)) {
const choices = await session.options(field.name, '', 12)
if (choices.length) {
out.write(` ${field.label} choices: ${choices.map((c) => `${c.label} (${String(c.id)})`).join(', ')}${choices.length === 12 ? ', … (type part of a label to narrow)' : ''}\n`)
hint = 'label or id'
}
}
const answer = (await question(rl, `${field.label}${field.isRequired ? ' *' : ''} [${hint}]${current ? ` (${current})` : ''}: `, signal)).trim()
if (answer === '' && current) return
if (answer === '' && !field.isRequired) {
await session.set(field.name, null)
return
}
if (answer === '' && field.isRequired) {
out.write(' required\n')
continue
}
try {
const change = await session.set(field.name, answer)
const problem = session.validate([field.name])[field.name]
if (problem) {
out.write(` ${field.label} ${problem}\n`)
continue
}
if (change.cleared.length) out.write(` cleared ${change.cleared.map((n) => session.fields[n]?.label ?? n).join(', ')} (depends on ${field.label})\n`)
if (change.disabled) out.write(` the form is disabled: ${change.disabled.message ?? ''}\n`)
return
} catch (error) {
if (error instanceof ChoiceError) {
out.write(` ${error.message}\n`)
continue
}
throw error
}
}
}
/** Asks for a secret on the terminal without echoing it (a password). */
export async function askHidden(text: string, options: { out?: NodeJS.WritableStream; input?: NodeJS.ReadableStream } = {}): Promise<string> {
const input = (options.input ?? process.stdin) as NodeJS.ReadStream
const out = options.out ?? process.stderr
if (!input.isTTY) throw new UsageError('This question needs a terminal.')
out.write(text)
return await new Promise<string>((resolve, reject) => {
let value = ''
const wasRaw = input.isRaw
input.setRawMode?.(true)
input.resume()
const done = (error?: Error) => {
input.setRawMode?.(wasRaw)
input.pause()
input.off('data', onData)
out.write('\n')
if (error) reject(error)
else resolve(value)
}
const onData = (chunk: Buffer) => {
for (const ch of chunk.toString('utf8')) {
if (ch === '\r' || ch === '\n') return done()
if (ch === '\u0003') {
done()
process.exit(130)
}
if (ch === '\u0004') return done(new UsageError('No answer (end of input).'))
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1)
else value += ch
}
}
input.on('data', onData)
})
}
+149 -64
View File
@@ -4,79 +4,87 @@
*/
/**
* Credentials and the authenticated client for one server. Tokens are kept in
* ~/.config/qqq-cli/credentials.json (mode 0600), per server origin, or come from QQQ_TOKEN
* (any bearer token: an access token or a session UUID).
* Profiles, credentials and the authenticated client for one server. A profile names a server;
* its sign-in (OAuth2 tokens from the device flow, or a server session) lives in the OS keyring
* (macOS Keychain, Linux Secret Service, Windows DPAPI; a 0600 file on headless hosts or with
* QQQ_KEYRING=file). QQQ_TOKEN overrides everything with a bearer token.
*
* Sign-ins made by 0.1 in ~/.config/qqq-cli/credentials.json are imported once.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createUiApiClient, getNativeSignInConfig, isExpiring, refreshTokens,
UiApiError, createUiApiClient, getNativeSignInConfig, isExpiring, keepIdToken, refreshTokens,
type NativeSignInConfig, type TokenSet, type UiApiClient,
} from '@qrun-io/qqq-ui-api-client'
import { defaultSecretStore, readConfig, resolveProfile, sessionCookieHeader, type StoredLogin as CoreStoredLogin, upsertProfile, type ResolvedProfile, type SecretStore } from '@qrun-io/qqq-core'
export const CLI_VERSION = '0.1.0-rc.1'
import { CLI_VERSION } from './version.js'
export { CLI_VERSION }
export const CLIENT_CHANNEL = `cli/qqq-cli ${CLI_VERSION}`
/** What is stored per server. */
export interface StoredLogin {
config: NativeSignInConfig
tokens: TokenSet
/** What is stored per profile: OAuth2 tokens, or a server session (manageSession, or a TABLE_BASED sessionId cookie). */
export type StoredLogin = CoreStoredLogin
let store: SecretStore | undefined
function secrets(): SecretStore {
store ??= defaultSecretStore()
return store
}
interface Store {
defaultServer?: string
servers: Record<string, StoredLogin>
/** The profile (and server) a command runs against. */
export interface Target extends ResolvedProfile {}
let selection: { profile?: string; server?: string } = {}
/** Called once by the program with the global --profile and --server options. */
export function selectTarget(options: { profile?: string; server?: string }): void {
selection = options
}
const dir = process.env.QQQ_CLI_HOME ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'qqq-cli')
const file = join(dir, 'credentials.json')
function readStore(): Store {
if (!existsSync(file)) return { servers: {} }
return JSON.parse(readFileSync(file, 'utf8')) as Store
/** The current target (main imports any 0.1 credentials file before a command runs). */
export function target(): Target {
return resolveProfile(selection)
}
function writeStore(store: Store): void {
mkdirSync(dir, { recursive: true, mode: 0o700 })
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 })
try {
chmodSync(file, 0o600)
} catch {
// not supported on every file system (e.g. some Windows volumes)
}
}
/** The server to use: --server, else QQQ_SERVER, else the last one signed in to. */
/** The server origin of the current target (kept for callers that only need the URL). */
export function resolveServer(option?: string): string {
const server = option ?? process.env.QQQ_SERVER ?? readStore().defaultServer
if (!server) throw new Error('No server: pass --server https://... or set QQQ_SERVER (or run `qqq login --server ...`).')
return new URL(server).origin
if (option) selection = { ...selection, server: option }
return target().server
}
/** Keeps a new sign-in for a server, and makes it the default server. */
export function saveLogin(server: string, login: StoredLogin): void {
const store = readStore()
store.servers[server] = login
store.defaultServer = server
writeStore(store)
function account(t: Target): string {
return `${t.name}@${t.server}`
}
/** Forgets a server's tokens. */
export function removeLogin(server: string): boolean {
const store = readStore()
const had = server in store.servers
delete store.servers[server]
if (store.defaultServer === server) delete store.defaultServer
writeStore(store)
return had
/**
* Keeps a new sign-in for the target, naming the profile and making it the default when it is the
* first. A profile that points at another server (a one-off --server) keeps its server unless
* `moveProfile` (login, after asking).
*/
export async function saveLogin(t: Target, login: StoredLogin, options: { moveProfile?: boolean } = {}): Promise<void> {
if (!t.movedFrom || options.moveProfile) upsertProfile(t.name, { server: t.server })
await secrets().set(account(t), JSON.stringify(login))
}
/** The stored sign-in of a server, if any. */
export function getLogin(server: string): StoredLogin | undefined {
return readStore().servers[server]
/** Forgets the target's sign-in (the profile stays). */
export async function removeLogin(t: Target): Promise<boolean> {
return secrets().delete(account(t))
}
/** The stored sign-in of the target, if any. */
export async function getLogin(t: Target): Promise<StoredLogin | undefined> {
const raw = await secrets().get(account(t))
if (!raw) return undefined
try {
return JSON.parse(raw) as StoredLogin
} catch {
// treating it as "not signed in" would hide the problem behind a 401
throw new Error(`The stored sign-in of profile "${t.name}" is unreadable; \`qqq logout\` then \`qqq login\` replaces it.`)
}
}
/** Fetches the native sign-in config of a server. */
@@ -85,34 +93,66 @@ export function nativeConfig(server: string): Promise<NativeSignInConfig> {
}
/**
* Returns the current bearer token for a server, refreshing it at the provider when it is about to
* The current bearer token for the target, refreshing it at the provider when it is about to
* expire (QQQ_TOKEN overrides everything).
*
* @param server - Server origin.
* @param force - Refresh even when not expiring (after a 401).
*/
export async function currentToken(server: string, force = false): Promise<string | undefined> {
export async function currentToken(t: Target, force = false): Promise<string | undefined> {
if (process.env.QQQ_TOKEN) return process.env.QQQ_TOKEN
const login = getLogin(server)
const login = await getLogin(t)
if (!login) return undefined
if (login.kind === 'session') return login.cookieName === 'sessionId' ? undefined : login.sessionUUID
if ((force || isExpiring(login.tokens)) && login.tokens.refreshToken) {
const tokens = await refreshTokens(login.config, login.tokens.refreshToken)
saveLogin(server, { config: login.config, tokens })
let refreshed: TokenSet
try {
refreshed = await refreshTokens(login.config, login.tokens.refreshToken)
} catch (error) {
// a refresh token the provider no longer honours (revoked, expired, provider reset) means signing in again
throw new UiApiError(401, { code: 'UNAUTHENTICATED', message: `The sign-in for ${t.server} has expired or was revoked at the provider (${error instanceof Error ? error.message : String(error)})` })
}
const tokens = keepIdToken(refreshed, login.tokens)
await saveLogin(t, { kind: 'oauth', config: login.config, tokens })
return tokens.accessToken
}
return login.tokens.accessToken
}
/** An authenticated UI API client for a server (refreshes once on 401). */
export function clientFor(server: string): UiApiClient {
/** The stored server session of the target, when the sign-in was session-based (sent as a cookie). */
export async function sessionCookieFor(t: Target): Promise<string | undefined> {
if (process.env.QQQ_TOKEN) return undefined
const login = await getLogin(t)
return login?.kind === 'session' && login.cookieName !== 'sessionId' ? login.sessionUUID : undefined
}
/** The whole Cookie header of the target's server session (sessionUUID=… or, TABLE_BASED, sessionId=…). */
export async function sessionCookieHeaderFor(t: Target): Promise<string | undefined> {
if (process.env.QQQ_TOKEN) return undefined
const login = await getLogin(t)
return login?.kind === 'session' ? sessionCookieHeader(login) : undefined
}
/** A fetch that adds a TABLE_BASED session's sessionId cookie (the typed client sends only sessionUUID). */
function sessionIdFetch(t: Target): typeof fetch {
return async (input, init) => {
const request = new Request(input as RequestInfo, init)
const login = process.env.QQQ_TOKEN ? undefined : await getLogin(t)
if (login?.kind === 'session' && login.cookieName === 'sessionId') request.headers.set('Cookie', sessionCookieHeader(login))
return fetch(request)
}
}
/** An authenticated UI API client for the target (refreshes once on 401). */
export function clientFor(t: Target): UiApiClient {
return createUiApiClient({
baseUrl: server,
baseUrl: t.server,
clientChannel: CLIENT_CHANNEL,
token: () => currentToken(server),
token: () => currentToken(t),
sessionCookie: () => sessionCookieFor(t),
fetch: sessionIdFetch(t),
onUnauthorized: async () => {
if (process.env.QQQ_TOKEN || !getLogin(server)?.tokens.refreshToken) return false
const login = await getLogin(t)
if (process.env.QQQ_TOKEN || !login || login.kind === 'session' || !login.tokens.refreshToken) return false
try {
await currentToken(server, true)
await currentToken(t, true)
return true
} catch {
return false
@@ -120,3 +160,48 @@ export function clientFor(server: string): UiApiClient {
},
})
}
/** A fetch that adds the target's authentication and client channel, for calls outside the typed client. */
export function authenticatedFetch(t: Target): typeof fetch {
return async (input, init) => {
const request = new Request(input as RequestInfo, init)
request.headers.set('X-QQQ-Client', CLIENT_CHANNEL)
const cookie = await sessionCookieHeaderFor(t)
if (cookie) {
request.headers.set('Cookie', cookie)
} else {
const token = await currentToken(t)
if (token) request.headers.set('Authorization', `Bearer ${token}`)
}
return fetch(request)
}
}
/** The secret store in use (for `qqq config show`). */
export function secretStoreKind(): string {
return secrets().kind
}
/**
* Imports ~/.config/qqq-cli/credentials.json (0.1) into profiles and the keyring, once. The file is
* renamed only after every sign-in was stored; when the keyring refuses one, the file stays and
* the next command tries again.
*/
export async function importLegacyCredentials(): Promise<void> {
const dir = process.env.QQQ_CLI_HOME ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'qqq-cli')
const file = join(dir, 'credentials.json')
if (!existsSync(file)) return
try {
const legacy = JSON.parse(readFileSync(file, 'utf8')) as { defaultServer?: string; servers?: Record<string, StoredLogin> }
const config = readConfig()
for (const [server, login] of Object.entries(legacy.servers ?? {})) {
const name = Object.entries(config.profiles).find(([, p]) => p.server === server)?.[0] ?? new URL(server).host.replace(/[^A-Za-z0-9.-]+/g, '-')
await secrets().set(`${name}@${server}`, JSON.stringify(login))
upsertProfile(name, { server, note: 'imported from qqq-cli 0.1' }, { makeDefault: legacy.defaultServer === server })
}
renameSync(file, `${file}.imported`)
process.stderr.write(`Imported the 0.1 sign-ins from ${file} into profiles (${file}.imported kept).\n`)
} catch (error) {
process.stderr.write(`warning: could not import the 0.1 sign-ins from ${file} (${error instanceof Error ? error.message : String(error)}); the file is left as it was\n`)
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { linkCommand, plainAnswer, renderAnswer } from '../commands/assistant.js'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TEST_SOURCE } from './harness.js'
import { runCli, sandbox } from './harness.js'
import { StubResponse, startStub, type J, type Stub } from './stub.js'
const sse = (events: J[]) => events.map((e, i) => `event: ${String(e.type)}\nid: ${i + 1}\ndata: ${JSON.stringify({ seq: i + 1, ...e })}\n\n`).join('')
const TURN = sse([
{ type: 'message.start', turnId: 4, conversationId: 12, userMessageId: 1, messageId: 2 },
{ type: 'tool.start', messageId: 2, toolCallId: 'a', name: 'count_records', summary: 'Counting Customer' },
{ type: 'tool.result', messageId: 2, toolCallId: 'a', name: 'count_records', outcome: 'OK', summary: 'Counted',
render: { type: 'count', tableName: 'customer', tableLabel: 'Customer', count: 42, openHref: '/app/customer' },
recordRefs: [{ tableName: 'customer', tableLabel: 'Customer', id: 5, label: 'Acme', href: '/app/customer/5' }] },
{ type: 'text.delta', messageId: 2, text: 'There are **42** ' },
{ type: 'text.delta', messageId: 2, text: 'customers.' },
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
])
describe('qqq assistant against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
let enabled = true
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
before(async () => {
box = sandbox()
stub = await startStub([
[/^GET \/version$/, () => ({ apiVersion: '1.9.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'assistant', version: '1.7.0' }] })],
[/^GET \/assistant\/config$/, () => (enabled
? { enabled: true, model: 'llama3.3:70b-instruct-q4_K_M', shortcut: 'mod+j', limits: { maxTurnSeconds: 900 }, profile: { displayName: 'Assistant', starterPrompts: ['How many customers are there?'] } }
: { enabled: false, reason: 'NOT_CONFIGURED', message: 'No model provider is configured on this server.', model: 'claude-sonnet-5-5', profile: { starterPrompts: [] } })],
[/^POST \/assistant\/conversations$/, () => ({ id: 12, title: null, status: 'ACTIVE' })],
[/^POST \/assistant\/conversations\/\d+\/turns$/, () => new StubResponse(200, TURN, { 'content-type': 'text/event-stream' })],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('config: the model, or the reason and what to configure', async () => {
enabled = true
const r = await qqq(['-O', 'table', 'assistant', 'config'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /status\s+enabled, model llama3.3:70b-instruct-q4_K_M/)
assert.match(r.stdout, /try asking\s+How many customers are there\?/)
enabled = false
const off = await qqq(['assistant', 'config', '--json'])
assert.deepEqual(JSON.parse(off.stdout), { installed: true, enabled: false, reason: 'NOT_CONFIGURED', message: 'No model provider is configured on this server.', model: 'claude-sonnet-5-5', starterPrompts: [] })
})
test('ask streams the answer, then numbers and links; sends the page context', async () => {
enabled = true
const r = await qqq(['-O', 'table', 'assistant', 'ask', '--table', 'customer', '--record', '5', 'how', 'many', 'customers?'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^There are 42 customers\.\n/)
assert.match(r.stdout, /Customer\s+42\n/)
assert.match(r.stdout, /Links\n/)
assert.match(r.stdout, /\[1\]\s+Acme \(Customer\)\s+qqq table get customer 5/)
assert.match(r.stdout, /\[2\]\s+Customer \(42\)\s+qqq table query customer/)
assert.doesNotMatch(r.stdout, /\/app\//)
assert.match(r.stderr, /Counting Customer/)
assert.match(r.stderr, /conversation 12; continue with: qqq assistant ask -c 12/)
const created = stub.requests.find((q) => q.path === '/assistant/conversations')!
assert.deepEqual(created.json, { context: { route: '/app/customer/5', tableName: 'customer', recordId: '5' } })
const turn = [...stub.requests].reverse().find((q) => q.path.endsWith('/turns'))!
assert.deepEqual(turn.json, { text: 'how many customers?', pageContext: { route: '/app/customer/5', tableName: 'customer', recordId: '5' }, concurrency: 'supersede' })
})
test('ask --json prints the folded answer; -c continues without a new conversation', async () => {
enabled = true
const before = stub.requests.filter((q) => q.path === '/assistant/conversations').length
const r = await qqq(['assistant', 'ask', '-c', '12', '--json', 'and orders?'])
assert.equal(r.code, 0, r.stderr)
const answer = JSON.parse(r.stdout) as J
assert.equal(answer.text, 'There are **42** customers.')
assert.equal(answer.conversationId, 12)
assert.deepEqual((answer.numbers as J[])[0], { label: 'Customer', value: '42', href: '/app/customer' })
assert.equal(stub.requests.filter((q) => q.path === '/assistant/conversations').length, before)
})
test('ask when the server has no model provider: the reason, exit 4', async () => {
enabled = false
const r = await qqq(['assistant', 'ask', 'hello'])
assert.equal(r.code, 4)
assert.match(r.stderr, /No model provider is configured on this server\. \(NOT_CONFIGURED, HTTP 503\)/)
const usage = await qqq(['assistant', 'ask', '--record', '1', 'x'])
assert.equal(usage.code, 2)
})
test('plain answer text drops markdown markers', () => {
assert.equal(plainAnswer('## Total\nThere are **42** `customers` and __3__ orders.'), 'Total\nThere are 42 customers and 3 orders.')
})
test('the 8078 answer about unhealthy services renders as text: no entities, no URLs, a sub-table, links once', () => {
const fixture = JSON.parse(readFileSync(join(TEST_SOURCE, '..', '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
const { text, links } = renderAnswer(fixture, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /Service\s+Status\s+Since/)
assert.match(text, /email \[1\]\s+DEGRADED/)
assert.match(text, /- Open unhealthy components \[4\]/)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
assert.deepEqual(links.map((l) => [l.n, l.label, l.open.split(' --')[0]]), [
[1, 'email', 'qqq table get health_component 4'],
[2, 'db_performance', 'qqq table get health_component 3'],
[3, 'esb', 'qqq table get health_component 10'],
[4, 'Open unhealthy components', 'qqq table query health_component'],
])
assert.match(links[3]!.open, /--filter-json '\{"criteria":\[\{"fieldName":"status","operator":"NOT_EQUALS","values":\["UP"\]\}\],"booleanOperator":"AND"\}'/)
assert.equal(linkCommand('/app/person/2'), 'qqq table get person 2')
assert.equal(linkCommand('https://x'), '')
})
})
+180
View File
@@ -0,0 +1,180 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The bulk commands against a stub server: saved bulk load profiles (the sample app has none),
* a load with a saved profile, upload templates, and the confirmation rules.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
type J = Record<string, unknown>
const fields = { id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false }, firstName: { name: 'firstName', label: 'First Name', type: 'STRING', isRequired: true }, lastName: { name: 'lastName', label: 'Last Name', type: 'STRING', isRequired: true } }
const structure = { isMain: true, isMany: false, tableName: 'person', label: 'Person', associationPath: null, associations: null, fields: [fields.firstName, fields.lastName] }
const upload = { headerValues: ['First', 'Last'], headerLetters: ['A', 'B'], bodyValuesPreview: [['Ann'], ['Lee']], fileBaseName: 'p.csv', tableStructure: structure, suggestedBulkLoadProfile: { version: 'v1', layout: 'FLAT', hasHeaderRow: true, isBulkEdit: false, keyFields: null, fieldList: [] } }
const steps = [
{ name: 'upload', label: 'Upload File', components: [{ type: 'HTML' }, { type: 'EDIT_FORM' }], formFields: [{ name: 'theFile', label: 'File', type: 'BLOB', isRequired: true }] },
{ name: 'fileMapping', label: 'File Mapping', components: [{ type: 'BULK_LOAD_FILE_MAPPING_FORM' }] },
{ name: 'review', label: 'Review', components: [{ type: 'BULK_LOAD_PROFILE_FORM' }, { type: 'VALIDATION_REVIEW_SCREEN' }] },
{ name: 'result', label: 'Result', components: [{ type: 'BULK_LOAD_PROFILE_FORM' }, { type: 'PROCESS_SUMMARY_RESULTS' }] },
]
interface Seen { path: string; values: J; body: string }
async function stub(): Promise<{ url: string; seen: Seen[]; profiles: J[]; close(): Promise<void> }> {
const seen: Seen[] = []
let validated = false
const profiles: J[] = [
{ id: 1, label: 'Mine', tableName: 'person', userId: 'me@example.com', isBulkEdit: false, mappingJson: JSON.stringify({ version: 'v1', layout: 'FLAT', hasHeaderRow: true, isBulkEdit: false, keyFields: null, fieldList: [{ fieldName: 'firstName', columnIndex: 0, headerName: 'First' }, { fieldName: 'lastName', columnIndex: 1, headerName: 'Last' }] }) },
{ id: 2, label: 'Theirs', tableName: 'person', userId: 'bob@example.com', isBulkEdit: false, mappingJson: JSON.stringify({ version: 'v1', layout: 'FLAT', hasHeaderRow: true, fieldList: [{ fieldName: 'lastName', defaultValue: 'X' }] }) },
]
const send = (res: ServerResponse, body: unknown) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const handle = (req: IncomingMessage, body: string, res: ServerResponse) => {
const url = new URL(req.url ?? '/', 'http://x')
const path = url.pathname.replace(/^\/qqq\/v1/, '')
const m = /name="values"\r\n\r\n([\s\S]*?)\r\n--/.exec(body)
const values = m ? JSON.parse(m[1]!) as J : {}
seen.push({ path: `${req.method} ${path}`, values, body })
if (path === '/version') return send(res, { apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [] })
if (path === '/metaData') return send(res, { tables: { person: { name: 'person', label: 'Person' } }, processes: { 'person.bulkInsert': { name: 'person.bulkInsert', label: 'Person Bulk Insert', tableName: 'person', isHidden: true }, querySavedBulkLoadProfile: {}, storeSavedBulkLoadProfile: {}, deleteSavedBulkLoadProfile: {} }, apps: {} })
if (path === '/metaData/table/person') return send(res, { name: 'person', label: 'Person', primaryKeyField: 'id', fields, clientCapabilities: { bulkInsert: { available: true, processName: 'person.bulkInsert' } } })
if (path === '/metaData/process/person.bulkInsert') return send(res, { name: 'person.bulkInsert', label: 'Person Bulk Insert', tableName: 'person', frontendSteps: steps })
if (path === '/processes/querySavedBulkLoadProfile/init') return send(res, { type: 'COMPLETE', processUUID: 'q', values: { savedBulkLoadProfileList: profiles.map((v) => ({ values: v })) } })
if (path === '/processes/storeSavedBulkLoadProfile/init') {
const record = { ...values, id: values.id ?? 3, userId: 'me@example.com' }
const i = profiles.findIndex((p) => p.id === record.id)
if (i >= 0) profiles[i] = record
else profiles.push(record)
return send(res, { type: 'COMPLETE', processUUID: 's', values: { savedBulkLoadProfileList: [{ values: record }] } })
}
if (path === '/processes/deleteSavedBulkLoadProfile/init') {
profiles.splice(profiles.findIndex((p) => p.id === values.id), 1)
return send(res, { type: 'COMPLETE', processUUID: 'd', values: {} })
}
if (path === '/processes/person.bulkInsert/init') validated = false
if (path === '/processes/person.bulkInsert/init') return send(res, { type: 'COMPLETE', processUUID: 'u', nextStep: 'upload', values: { tableName: 'person', tableStructure: structure, 'upload.html': '<p>Upload a file.</p><a href="data:text/csv;base64,Rmlyc3QsTGFzdAo=" download="Person - Flat.csv">Person - Flat.csv</a>' } })
if (path === '/processes/person.bulkInsert/u/step/upload') return send(res, { type: 'COMPLETE', processUUID: 'u', nextStep: 'fileMapping', backStep: 'prepareFileUpload', values: upload })
if (path === '/processes/person.bulkInsert/u/step/fileMapping') return send(res, { type: 'COMPLETE', processUUID: 'u', nextStep: 'review', values: { supportsFullValidation: true, previewMessage: 'This is a preview.' } })
if (path === '/processes/person.bulkInsert/u/step/review') {
if (values.doFullValidation === 'true' && !validated) {
validated = true
return send(res, { type: 'COMPLETE', processUUID: 'u', nextStep: 'review', values: { supportsFullValidation: true, validationSummary: [{ status: 'OK', count: 1, message: 'Person record will be inserted.' }] } })
}
return send(res, { type: 'COMPLETE', processUUID: 'u', nextStep: 'result', values: { processResults: [{ status: 'OK', count: 1, message: 'Person record was inserted.' }] } })
}
if (/\/cancel$/.test(path)) return send(res, {})
if (/\/records$/.test(path)) return send(res, { records: [{ values: { firstName: 'Ann', lastName: 'Lee' } }], totalRecords: 1 })
if (path === '/manageSession') return send(res, { uuid: 's1', values: { user: { email: 'me@example.com' } } })
res.writeHead(404, { 'content-type': 'application/json' })
res.end(JSON.stringify({ code: 'NOT_FOUND', message: `no route ${req.method} ${path}` }))
}
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => handle(req, Buffer.concat(chunks).toString('utf8'), res))
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, seen, profiles, close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(() => resolve()) }) }
}
describe('bulk commands against a stub server', () => {
let server: Awaited<ReturnType<typeof stub>>
let box: ReturnType<typeof sandbox>
let dir: string
// a JWT whose claims name the user (the owner of profile 1)
const token = `x.${Buffer.from(JSON.stringify({ email: 'me@example.com' })).toString('base64url')}.y`
const qqq = (args: string[]) => runCli(['--server', server.url, ...args], { QQQ_TOKEN: token, ...box.env })
before(async () => {
server = await stub()
box = sandbox()
dir = mkdtempSync(join(tmpdir(), 'qqq-bulk-'))
writeFileSync(join(dir, 'p.csv'), 'First,Last\nAnn,Lee\n')
})
after(async () => {
await server.close()
box.dispose()
})
test('bulk-profile list shows yours first, then shared with you', async () => {
const r = await qqq(['bulk-profile', 'list', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^id,label,owner,layout,fields\n1,Mine,you,FLAT,"firstName, lastName"\n2,Theirs,shared by bob@example.com,FLAT,lastName\n/)
})
test('only the owner may rename or delete a profile', async () => {
const r = await qqq(['bulk-profile', 'delete', 'person', 'Theirs', '--yes'])
assert.equal(r.code, 2)
assert.match(r.stderr, /shared with you by bob@example.com; only its owner may delete it/)
const rename = await qqq(['bulk-profile', 'rename', 'person', 'Theirs', 'Nope'])
assert.equal(rename.code, 2)
})
test('saving over your profile shows the changes and needs --yes without a terminal', async () => {
writeFileSync(join(dir, 'm.yaml'), 'fields:\n firstName: Last\n lastName: Last\n')
const refused = await qqq(['bulk-profile', 'save', 'person', '-n', 'Mine', '--mapping', join(dir, 'm.yaml'), '--file', join(dir, 'p.csv'), '--replace'])
assert.equal(refused.code, 2)
assert.match(refused.stderr, /Changes to "Mine":\n {2}First Name: column First -> Last/)
const dup = await qqq(['bulk-profile', 'save', 'person', '-n', 'mine', '--mapping', join(dir, 'm.yaml'), '--file', join(dir, 'p.csv')])
assert.match(dup.stderr, /already have a profile "Mine"/)
const ok = await qqq(['bulk-profile', 'save', 'person', '-n', 'Mine', '--mapping', join(dir, 'm.yaml'), '--file', join(dir, 'p.csv'), '--replace', '--yes', '-O', 'csv'])
assert.equal(ok.code, 0, ok.stderr)
assert.match(ok.stdout, /1,Mine,updated/)
const stored = JSON.parse(String(server.profiles.find((p) => p.id === 1)!.mappingJson)) as { fieldList: J[] }
assert.deepEqual(stored.fieldList[0], { fieldName: 'firstName', columnIndex: 1, headerName: 'Last', doValueMapping: false, clearIfEmpty: false })
})
test('rename and delete your own profile', async () => {
const r = await qqq(['bulk-profile', 'rename', 'person', '1', 'Renamed', '-O', 'csv'])
assert.match(r.stdout, /1,Renamed,Mine/)
const save = await qqq(['bulk-profile', 'save', 'person', '-n', 'Temp', '--mapping', join(dir, 'm.yaml'), '--file', join(dir, 'p.csv'), '-O', 'csv'])
assert.match(save.stdout, /3,Temp,created/)
const d = await qqq(['bulk-profile', 'delete', 'person', 'Temp', '--yes'])
assert.equal(d.code, 0, d.stderr)
assert.ok(!server.profiles.some((p) => p.label === 'Temp'))
})
test('bulk-insert with a saved profile sends its id and the flat profile values; --yes loads', async () => {
const r = await qqq(['table', 'bulk-insert', 'person', '--file', join(dir, 'p.csv'), '--saved', 'Theirs', '--column', 'firstName=First', '--yes', '-O', 'table'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /\[OK\] 1 Person record was inserted\./)
const mapping = server.seen.find((s) => s.path.endsWith('/step/fileMapping'))!
assert.equal(mapping.values.savedBulkLoadProfileId, '2')
assert.deepEqual(JSON.parse(String(mapping.values.fieldListJSON)), [{ fieldName: 'lastName', defaultValue: 'X' }, { fieldName: 'firstName', columnIndex: 0, headerName: 'First', doValueMapping: false, clearIfEmpty: false }])
assert.ok(server.seen.some((s) => s.path.endsWith('/step/upload') && /filename="p.csv"/.test(s.body)))
assert.match(r.stderr, /Validation:\n\[OK\] 1 Person record will be inserted\./)
})
test('without a terminal and without --yes the load is cancelled with exit 2', async () => {
const r = await qqq(['table', 'bulk-insert', 'person', '--file', join(dir, 'p.csv'), '--column', 'firstName=A', '--column', 'lastName=B', '--quiet'])
assert.equal(r.code, 2)
assert.match(r.stderr, /Confirmation needed: pass --yes/)
assert.ok(server.seen.some((s) => s.path.endsWith('/u/cancel')))
})
test('an incomplete mapping is refused before the step, naming the field', async () => {
const r = await qqq(['table', 'bulk-insert', 'person', '--file', join(dir, 'p.csv'), '--quiet', '--yes'])
assert.equal(r.code, 2)
assert.match(r.stderr, /First Name: choose a column or give a default value/)
})
test('--template saves the template the instructions link to', async () => {
const out = join(dir, 't.csv')
const r = await qqq(['table', 'bulk-insert', 'person', '--template', 'flat', '--out', out])
assert.equal(r.code, 0, r.stderr)
assert.equal(readFileSync(out, 'utf8'), 'First,Last\n')
})
})
+337
View File
@@ -0,0 +1,337 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Golden-output tests for the CLI as a whole: the built CLI (dist/main.js) against a fake server
* answering with recorded sample-server responses (see harness.ts). Each case's stdout, stderr
* and exit code are compared with src/test/golden/<case>.txt; UPDATE_GOLDEN=1 rewrites them.
*/
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { after, before, describe, test } from 'node:test'
import { TEST_SOURCE, runCli, sandbox, startFakeServer, type CliResult, type FakeServer } from './harness.js'
const GOLDEN = join(TEST_SOURCE, 'golden')
const UPDATE = process.env.UPDATE_GOLDEN === '1'
let server: FakeServer
let box: ReturnType<typeof sandbox>
before(async () => {
server = await startFakeServer()
box = sandbox()
})
after(async () => {
await server.close()
box.dispose()
})
/** Runs the CLI against the fake server, signed in with a bearer token. */
function qqq(args: string[], env: Record<string, string> = {}, input?: string): Promise<CliResult> {
return runCli(['--server', server.url, ...args], { QQQ_TOKEN: 'test-token', ...box.env, ...env }, input)
}
function normalize(text: string): string {
// paths under the sandbox home are compared with / separators, so the golden files hold on Windows too
return text.split(server.url).join('http://fake').split(box.env.HOME!).join('<home>').replace(/<home>[^\s"']*/g, (path) => path.replace(/\\/g, '/')).replace(/_\d{4}-\d{2}-\d{2}_\d{6}/g, '_<stamp>').replace(/\r\n/g, '\n')
}
/** Compares a run with its golden file (or writes it with UPDATE_GOLDEN=1). */
function golden(name: string, result: CliResult): void {
const actual = `${normalize(result.stdout)}--- stderr ---\n${normalize(result.stderr)}--- exit ${result.code}\n`
const file = join(GOLDEN, `${name}.txt`)
if (UPDATE || !existsSync(file)) {
mkdirSync(GOLDEN, { recursive: true })
writeFileSync(file, actual)
if (!UPDATE) assert.fail(`wrote missing golden ${name}.txt; check it and run again`)
return
}
assert.equal(actual, readFileSync(file, 'utf8'), `golden ${name}.txt (UPDATE_GOLDEN=1 rewrites it)`)
}
/** The last request to a path. */
function lastRequest(method: string, path: RegExp) {
return [...server.requests].reverse().find((r) => r.method === method && path.test(r.path))
}
describe('tables', { concurrency: true }, () => {
test('table list', async () => golden('table-list', await qqq(['table', 'list', '-O', 'table'])))
test('table describe', async () => golden('table-describe', await qqq(['table', 'describe', 'person', '-O', 'table'])))
test('table query as a table', async () => golden('query-table', await qqq(['table', 'query', 'person', '-O', 'table'])))
test('table query as json', async () => golden('query-json', await qqq(['table', 'query', 'person', '-l', '2', '-O', 'json'])))
test('table query as csv, with --fields and a page that may continue', async () => golden('query-csv', await qqq(['table', 'query', 'person', '--fields', 'id,firstName,createDate', '-l', '2', '-O', 'csv'])))
test('table query as text', async () => golden('query-text', await qqq(['table', 'query', 'person', '-f', 'id,lastName', '-O', 'text'])))
test('table query as yaml with --query', async () => golden('query-yaml', await qqq(['table', 'query', 'person', '-O', 'yaml', '-Q', '[].{id: values.id, name: recordLabel}'])))
test('table query --utc shows raw instants', async () => golden('query-utc', await qqq(['--utc', 'table', 'query', 'person', '--fields', 'id,createDate', '-O', 'table'])))
test('table count', async () => golden('table-count', await qqq(['table', 'count', 'person', '-O', 'table'])))
test('table get as the record view', async () => golden('get-table', await qqq(['table', 'get', 'person', '1', '-O', 'table'])))
test('table get as json', async () => golden('get-json', await qqq(['table', 'get', 'person', '1', '-O', 'json'])))
test('flat alias get', async () => golden('get-alias', await qqq(['get', 'person', '1', '-O', 'json', '-Q', 'values.firstName'])))
test('-V is --view on table query, never the version', async () => {
const result = await qqq(['table', 'query', 'person', '-V', 'mine'])
assert.doesNotMatch(result.stdout, /UI API client/)
golden('query-view-short', result)
})
})
describe('writes', () => {
test('table create with -s (short --set, no longer --server)', async () => {
golden('create', await qqq(['table', 'create', 'pet', '-s', 'name=Pixel', '-s', 'personId=1', '-s', 'speciesId=Cat', '-O', 'table']))
assert.deepEqual(JSON.parse(lastRequest('POST', /^\/table\/pet$/)!.body), { name: 'Pixel', personId: 1, speciesId: 2 })
})
test('table update of a contingent field uses the record\'s current dependencies', async () => {
golden('update-contingent', await qqq(['table', 'update', 'placeAddress', '1', '--set', 'cityId=Springfield', '-O', 'table']))
assert.deepEqual(JSON.parse(lastRequest('PATCH', /^\/table\/placeAddress\/1$/)!.body), { cityId: 2 })
})
test('table update of a dependency sends the dependents it cleared', async () => {
golden('update-clears', await qqq(['table', 'update', 'placeAddress', '1', '--set', 'stateId=Missouri', '-O', 'table']))
assert.deepEqual(JSON.parse(lastRequest('PATCH', /^\/table\/placeAddress\/1$/)!.body), { stateId: 2, cityId: null })
})
test('table delete without a terminal and without --yes refuses (exit 2), sends nothing', async () => {
const before = server.requests.filter((r) => r.method === 'DELETE').length
golden('delete-no-tty', await qqq(['table', 'delete', 'pet', '7']))
assert.equal(server.requests.filter((r) => r.method === 'DELETE').length, before)
})
test('table delete --yes', async () => golden('delete-yes', await qqq(['table', 'delete', 'pet', '7', '--yes', '-O', 'table'])))
test('table create -i without a terminal is a usage error', async () => golden('create-interactive-no-tty', await qqq(['table', 'create', 'pet', '-i'])))
test('table export goes to the download directory', async () => {
const dir = join(box.env.HOME!, 'downloads')
const set = await qqq(['config', 'set', 'download-dir', dir])
assert.equal(set.code, 0, set.stderr)
const get = await qqq(['config', 'get', 'download-dir', '-O', 'json'])
assert.equal(JSON.parse(get.stdout), dir)
golden('export-download-dir', await qqq(['table', 'export', 'person']))
const [first] = readdirSync(dir)
assert.match(first!, /^person_\d{4}-\d{2}-\d{2}_\d{6}\.csv$/)
assert.equal(readFileSync(join(dir, first!), 'utf8'), 'id,firstName\n1,Avery\n2,Blair\n')
// a second export in the same second takes the next free name; a later one has its own stamp
const again = await qqq(['table', 'export', 'person'])
assert.match(again.stderr, /person_\d{4}-\d{2}-\d{2}_\d{6}(-1)?\.csv/)
assert.equal(readdirSync(dir).length, 2)
// an explicit --out is left exactly as given
await qqq(['table', 'export', 'person', '-o', join(dir, 'person.csv')])
assert.ok(readdirSync(dir).includes('person.csv'))
await qqq(['config', 'set', 'download-dir', ''])
})
})
describe('processes', () => {
test('process describe', async () => golden('process-describe', await qqq(['process', 'describe', 'sleepInteractive', '-O', 'table'])))
test('process run, synchronous step', async () => golden('process-run-sync', await qqq(['process', 'run', 'sleepInteractive', '-v', 'sleepMillis=100', '--timeout', '60000', '-O', 'table'])))
test('process run, asynchronous job polled to the end', async () => golden('process-run-async', await qqq(['process', 'run', 'sleepInteractive', '-v', 'sleepMillis=100', '-O', 'json', '-Q', 'values'])))
test('process run with a bad --timeout is a usage error before anything runs', async () => golden('process-run-bad-timeout', await qqq(['process', 'run', 'sleepInteractive', '--timeout', 'soon'])))
})
describe('widgets, api, extensions', { concurrency: true }, () => {
test('widget get as bars', async () => golden('widget-get', await qqq(['widget', 'get', 'SampleLineChartWidget', '--ascii', '-O', 'table'], { COLUMNS: '80' })))
test('widget get --csv', async () => golden('widget-csv', await qqq(['widget', 'get', 'SampleLineChartWidget', '--csv'])))
test('api by operation id with --query', async () => golden('api-call', await qqq(['api', 'getTableMetaData', '--path', 'tableName=person', '-Q', 'label'])))
test('api with a missing path parameter is a usage error', async () => golden('api-missing-path', await qqq(['api', 'getTableMetaData'])))
test('extensions', async () => golden('extensions', await qqq(['extensions', '-O', 'table'])))
test('an installed extension noun', async () => golden('esb-overview', await qqq(['esb', 'overview', '-O', 'table'])))
test('an extension the server does not list exits 5', async () => golden('collab-not-installed', await qqq(['collab', 'favorites', 'list'])))
})
describe('exit codes', { concurrency: true }, () => {
for (const [status, code] of [[400, 6], [401, 3], [403, 3], [404, 5], [500, 70], [503, 4]] as const) {
test(`HTTP ${status} exits ${code}`, async () => {
const result = await qqq(['table', 'describe', `err${status}`])
assert.equal(result.code, code, result.stderr)
golden(`exit-${status}`, result)
})
}
test('a refused token exits 3', async () => {
// a sandbox of its own: the shared one holds metadata cached by other tests
const local = sandbox()
try {
golden('exit-expired-token', await runCli(['--server', server.url, 'table', 'list'], { ...local.env, QQQ_TOKEN: 'expired' }))
} finally {
local.dispose()
}
})
test('an unreachable server exits 4', async () => {
const result = await runCli(['--server', 'http://127.0.0.1:59999', 'table', 'list'], { QQQ_TOKEN: 't', ...box.env })
assert.equal(result.code, 4, result.stderr)
assert.match(result.stderr, /cannot reach the server/)
})
test('an unknown command exits 2', async () => {
const result = await qqq(['tabel', 'list'])
assert.equal(result.code, 2)
})
test('a bad --limit exits 2 before any request', async () => golden('bad-limit', await qqq(['table', 'query', 'person', '-l', 'many'])))
})
describe('doctor', () => {
test('doctor passes against a healthy server', async () => {
const result = await qqq(['doctor', '-O', 'json'])
assert.equal(result.code, 0, result.stdout + result.stderr)
const report = JSON.parse(result.stdout) as { ok: boolean; checks: Array<{ area: string; check: string; status: string; detail: string }> }
assert.equal(report.ok, true)
assert.match(report.checks[0]!.detail, /^qqq \S+; npm package or source checkout; Node \d+\.\d+\.\d+; \w+ \S+ \w+$/)
assert.deepEqual(report.checks.filter((x) => x.area !== 'terminal').map((x) => [x.check, x.status]).slice(0, 7), [['build', 'ok'], ['profile', 'ok'], ['reachable', 'ok'], ['UI API version', 'ok'], ['authentication', 'ok'], ['signed in', 'ok'], ['extensions', 'ok']])
})
test('doctor without a sign-in exits 1 and says what to do', async () => {
const local = sandbox()
try {
const result = await runCli(['--server', server.url, 'doctor', '-O', 'table'], local.env)
assert.equal(result.code, 1)
assert.match(result.stdout, /signed in\s+fail\s+no sign-in for profile/)
assert.match(result.stdout, /checks failed: signed in/)
} finally {
local.dispose()
}
})
test('doctor on an unreachable server exits 1', async () => {
const result = await runCli(['--server', 'http://127.0.0.1:59999', 'doctor', '-O', 'json'], box.env)
assert.equal(result.code, 1)
assert.equal((JSON.parse(result.stdout) as { checks: Array<{ check: string; status: string }> }).checks.find((x) => x.check === 'reachable')?.status, 'fail')
})
test('UI API version support', async () => {
const { versionSupport } = await import('../commands/doctor.js')
assert.equal(versionSupport('1.8.0', [1]).status, 'ok')
assert.equal(versionSupport('1.5.2', [1]).status, 'fail')
assert.equal(versionSupport('1.9.0', [1]).status, 'warn')
assert.equal(versionSupport('2.0.0', [2]).status, 'fail')
})
test('doctor words the supported UI API range exactly as --version does', async () => {
const { versionSupport } = await import('../commands/doctor.js')
const { UI_API_RANGE } = await import('../version.js')
assert.equal(UI_API_RANGE, '>=1.6.0 <2.0.0')
const version = await runCli(['--version'], box.env)
assert.match(version.stdout, new RegExp(`servers UI API ${UI_API_RANGE}`))
for (const [server, status] of [['1.6.0', 'ok'], ['1.8.0', 'ok'], ['1.9.0', 'warn'], ['1.5.2', 'fail']] as const) {
const result = versionSupport(server, [1])
assert.equal(result.status, status, server)
assert.ok(result.detail.includes(UI_API_RANGE), `${server}: ${result.detail}`)
assert.ok(!/supports \d+\.\d+\.\d+ to /.test(result.detail), `${server}: ${result.detail}`)
}
assert.match(versionSupport('1.9.0', [1]).detail, /^1\.9\.0; supported \(/)
})
})
describe('merged with the TUI stream', () => {
test('config set|get theme and download-mode use the TUI\'s ui helpers', async () => {
const local = sandbox()
try {
assert.equal((await runCli(['config', 'set', 'theme', 'classic'], local.env)).code, 0)
assert.equal(JSON.parse((await runCli(['config', 'get', 'theme', '-O', 'json'], local.env)).stdout), 'classic')
assert.equal((await runCli(['config', 'set', 'download-mode', 'loud'], local.env)).code, 2)
assert.equal((await runCli(['config', 'set', 'download-mode', 'silent'], local.env)).code, 0)
assert.equal((await runCli(['config', 'set', 'download-dir', join(local.env.HOME!, 'dl')], local.env)).code, 0)
const ui = (JSON.parse(readFileSync(join(local.env.QQQ_CONFIG_DIR!, 'config.json'), 'utf8')) as { ui: unknown }).ui
assert.deepEqual(ui, { theme: 'classic', files: { downloadMode: 'silent', downloadDir: join(local.env.HOME!, 'dl') } })
} finally {
local.dispose()
}
})
test('doctor reports the terminal through the kernel check', async () => {
const result = await qqq(['doctor', '-O', 'json'])
const checks = (JSON.parse(result.stdout) as { checks: Array<{ area: string; status: string }> }).checks.filter((x) => x.area === 'terminal')
assert.ok(checks.length > 0)
assert.ok(checks.every((x) => x.status !== 'skip'))
})
test('runCli captures what commands write straight to stdout (export --out -) for the TUI pane', async () => {
// in a child process: capturing process.stdout inside the test runner would eat its own reports
const script = `
import { runCli } from ${JSON.stringify(new URL('../program.js', import.meta.url).href)}
let out = '', err = ''
const io = { stdout: (t) => (out += t), stderr: (t) => (err += t) }
const exported = await runCli(['--server', process.argv[1], 'table', 'export', 'person', '--out', '-'], io)
const exportOut = out
out = ''
const refused = await runCli(['--server', process.argv[1], 'table', 'delete', 'pet', '7'], io)
process.stdout.write(JSON.stringify({ exported, exportOut, refused, err }))`
const result = await new Promise<string>((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script, server.url], { env: { ...process.env, ...box.env, QQQ_TOKEN: 'test-token' } })
let text = ''
child.stdout.on('data', (d: Buffer) => (text += d.toString('utf8')))
child.on('error', reject)
child.on('close', () => resolve(text))
})
const r = JSON.parse(result) as { exported: number; exportOut: string; refused: number; err: string }
assert.equal(r.exported, 0, r.err)
assert.equal(r.exportOut, 'id,firstName\n1,Avery\n2,Blair\n')
assert.equal(r.refused, 2)
assert.match(r.err, /Pass --yes/)
})
})
describe('local state', () => {
test('a corrupt config.json is reported and left alone', async () => {
const local = sandbox()
try {
mkdirSync(local.env.QQQ_CONFIG_DIR!, { recursive: true })
writeFileSync(join(local.env.QQQ_CONFIG_DIR!, 'config.json'), '{"profiles": {')
const result = await runCli(['config', 'profile', 'add', 'x', 'http://localhost:1'], local.env)
assert.equal(result.code, 1)
assert.match(result.stderr, /not valid JSON/)
assert.equal(readFileSync(join(local.env.QQQ_CONFIG_DIR!, 'config.json'), 'utf8'), '{"profiles": {')
} finally {
local.dispose()
}
})
test('0.1 sign-ins are imported into a profile and the file renamed', async () => {
const local = sandbox()
try {
mkdirSync(local.env.QQQ_CLI_HOME!, { recursive: true })
const legacy = { defaultServer: server.url, servers: { [server.url]: { kind: 'session', authType: 'MOCK', sessionUUID: 'abc', createdAt: '2026-01-01T00:00:00Z' } } }
writeFileSync(join(local.env.QQQ_CLI_HOME!, 'credentials.json'), JSON.stringify(legacy))
const result = await runCli(['config', 'profile', 'list', '-O', 'json'], local.env)
assert.equal(result.code, 0, result.stderr)
assert.match(result.stderr, /Imported the 0\.1 sign-ins/)
assert.deepEqual((JSON.parse(result.stdout) as Array<{ signedIn: string }>).map((p) => p.signedIn), ['session'])
assert.ok(existsSync(join(local.env.QQQ_CLI_HOME!, 'credentials.json.imported')))
} finally {
local.dispose()
}
})
test('qqq profile add|edit|list|use|remove agree with the Servers dialog: short name, description, sign-in mode', async () => {
const local = sandbox()
try {
const file = join(local.env.QQQ_CONFIG_DIR!, 'config.json')
// a 1.0.0-rc.1 file (with a note) loads and keeps what it had
mkdirSync(local.env.QQQ_CONFIG_DIR!, { recursive: true })
writeFileSync(file, JSON.stringify({ defaultProfile: 'old', profiles: { old: { server: 'http://old.test', note: 'from rc.1', createdAt: '2026-10-31T00:00:00Z', ui: { theme: 'light' } } }, ui: { theme: 'dark' } }))
let r = await runCli(['profile', 'add', 'sample', server.url, '--short', 'smp', '--description', 'The sample server', '--check'], local.env)
assert.equal(r.code, 0, r.stderr)
assert.match(r.stderr, /UI API 1\.\d+\.\d+/)
r = await runCli(['profile', 'add', 'down', 'http://127.0.0.1:9', '--check'], local.env)
assert.equal(r.code, 2)
assert.match(r.stderr, /did not answer/)
r = await runCli(['profile', 'add', 'bad', server.url, '--short', 'x'], local.env)
assert.equal(r.code, 2)
assert.match(r.stderr, /2 to 6 characters/)
r = await runCli(['profile', 'edit', 'sample', '--auth-mode', 'device', '--description', 'Sample, edited', '--rename', 'samp'], local.env)
assert.equal(r.code, 0, r.stderr)
r = await runCli(['config', 'profile', 'use', 'samp'], local.env)
assert.equal(r.code, 0, r.stderr)
r = await runCli(['profile', 'list', '-O', 'json', '--probe'], local.env)
assert.equal(r.code, 0, r.stderr)
const rows = JSON.parse(r.stdout) as Array<Record<string, string>>
assert.deepEqual(rows.map((p) => [p.name, p.short, p.default, p.authMode, p.description]), [['old', 'old', '', 'auto', 'from rc.1'], ['samp', 'smp', '*', 'device', 'Sample, edited']])
assert.match(rows[1]!.status!, /^UI API /)
const cfg = JSON.parse(readFileSync(file, 'utf8')) as { defaultProfile: string; profiles: Record<string, Record<string, unknown>>; ui: unknown }
assert.deepEqual(cfg.profiles.old, { server: 'http://old.test', note: 'from rc.1', createdAt: '2026-10-31T00:00:00Z', ui: { theme: 'light' } })
assert.deepEqual(cfg.ui, { theme: 'dark' })
assert.equal(cfg.profiles.samp!.short, 'smp')
r = await runCli(['profile', 'edit', 'samp', '--short', ''], local.env)
assert.equal(r.code, 0, r.stderr)
assert.equal((JSON.parse(readFileSync(file, 'utf8')) as typeof cfg).profiles.samp!.short, undefined)
r = await runCli(['profile', 'remove', 'old'], local.env)
assert.equal(r.code, 0, r.stderr)
assert.deepEqual(Object.keys((JSON.parse(readFileSync(file, 'utf8')) as typeof cfg).profiles), ['samp'])
} finally {
local.dispose()
}
})
test('--version prints the version', async () => {
const result = await runCli(['--version'], box.env)
assert.equal(result.code, 0)
assert.match(result.stdout, /UI API client/)
})
})
@@ -0,0 +1,112 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Dashboards against a stub: a heavy 24-widget app loads each widget once, concurrently; a process widget runs its process. */
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
import { BASE_ROUTES, startStub, type J, type Stub } from './stub.js'
const kinds = ['statistics', 'barChart', 'lineChart', 'pieChart', 'table', 'table'] as const
const names = Array.from({ length: 24 }, (_, i) => `w${String(i).padStart(2, '0')}`)
const payload = (i: number): J => {
const type = kinds[i % kinds.length]!
if (type === 'statistics') return { type, count: 1000 + i, countContext: 'records' }
if (type === 'table') return { type, columns: [{ accessor: 'id', header: 'Id' }, { accessor: 'name', header: 'Name' }], rows: Array.from({ length: 100 }, (_, r) => ({ id: r + 1, name: `row ${r + 1}` })) }
return { type, chartData: { labels: ['a', 'b', 'c'], datasets: [{ label: 'n', data: [1, 2, 3] }] } }
}
/**
* Widget payloads answer after a hold: as soon as every widget request is in flight, or 300 ms after a
* request arrived, whichever is first. A concurrent loader therefore never waits on the clock, and the
* peak number of requests in flight measures concurrency without depending on machine speed.
*/
let inFlight = 0
let peakInFlight = 0
const held: Array<() => void> = []
function holdWidget(r: { path: string }): Promise<J> {
inFlight++
peakInFlight = Math.max(peakInFlight, inFlight)
return new Promise((resolve) => {
let done = false
const release = () => {
if (done) return
done = true
inFlight--
resolve(payload(Number(/w(\d+)/.exec(r.path)![1])))
}
held.push(release)
if (inFlight >= names.length) held.splice(0).forEach((f) => f())
else setTimeout(release, 300)
})
}
describe('dashboards against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
before(async () => {
box = sandbox()
const widgets: Record<string, J> = Object.fromEntries(names.map((n, i) => [n, { name: n, label: `Widget ${i}`, type: kinds[i % kinds.length], gridColumns: i % 3 === 0 ? 12 : 6 }]))
widgets.runIt = { name: 'runIt', label: 'Run It', type: 'process' }
widgets.filterSetup = { name: 'filterSetup', label: 'Filter', type: 'filterAndColumnsSetup' }
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: {}, processes: { hello: { name: 'hello', label: 'Hello' } }, widgets, apps: { heavy: { name: 'heavy', label: 'Heavy Dashboard', widgets: names, children: [] } }, appTree: [] })],
[/^POST \/widget\/runIt$/, () => ({ type: 'process', processMetaData: { name: 'hello', label: 'Hello' }, defaultValues: { greeting: 'hi' } })],
[/^POST \/widget\/w(\d+)$/, (r) => holdWidget(r)],
[/^GET \/metaData\/process\/hello$/, () => ({ name: 'hello', label: 'Hello', frontendSteps: [{ name: 'done', label: 'Done', components: [{ type: 'VIEW_FORM' }], viewFields: [{ name: 'reply', label: 'Reply', type: 'STRING' }] }] })],
[/^GET \/metaData\/process\/setup$/, () => ({ name: 'setup', label: 'Setup', frontendSteps: [{ name: 'pick', label: 'Pick', components: [{ type: 'EDIT_FORM' }], formFields: [{ name: 'queryFilterJson', label: 'Filter', type: 'TEXT', adornments: [{ type: 'WIDGET', values: { widgetName: 'filterSetup' } }] }] }, { name: 'done', label: 'Done', components: [{ type: 'VIEW_FORM' }], viewFields: [] }] })],
[/^POST \/processes\/setup\/init$/, () => ({ type: 'COMPLETE', processUUID: 's', nextStep: 'pick', values: {} })],
[/^POST \/processes\/setup\/s\/step\/pick$/, () => ({ type: 'COMPLETE', processUUID: 's', nextStep: 'done', values: {} })],
[/^POST \/processes\/hello\/init$/, (r) => ({ type: 'COMPLETE', processUUID: 'p', nextStep: 'done', values: { reply: `${String(r.values.greeting)} there` } })],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('a heavy dashboard: 24 widgets (statistics, charts, 100-row tables) load once each, concurrently, in declared order', async () => {
const started = Date.now()
const r = await qqq(['apps', 'show', 'heavy', '--widgets', '--no-counts', '-O', 'table'])
const elapsed = Date.now() - started
assert.equal(r.code, 0, r.stderr)
const calls = stub.requests.filter((q) => /^\/widget\/w\d+$/.test(q.path)).map((q) => q.path)
assert.equal(calls.length, 24)
assert.equal(new Set(calls).size, 24)
const order = [...r.stdout.matchAll(/== Widget (\d+) \((\d+)\/12\) ==/g)].map((m) => Number(m[1]))
assert.deepEqual(order, names.map((_, i) => i))
assert.match(r.stdout, /== Widget 0 \(12\/12\) ==\n1,000 records/)
assert.match(r.stdout, /row 100/)
// The CLI's loader has no concurrency cap (loadDashboard starts every widget at once), so the widget
// requests must overlap; a serial loader would peak at 1.
assert.ok(peakInFlight > 1, `widget requests in flight at once: ${peakInFlight}`)
if (!process.env.CI) {
// Local runs only: with no cap all 24 are in flight together, and the load is far from the
// 24 x 300 ms a serial loader would need (the bound is generous, CLI start-up included).
assert.equal(peakInFlight, names.length)
assert.ok(elapsed < 6000, `took ${elapsed} ms`)
}
assert.match(r.stderr, /24 widgets loaded once each/)
})
test('a process widget runs its process with the widget\'s default values', async () => {
const shown = await qqq(['widget', 'get', 'runIt', '-O', 'table'])
assert.match(shown.stdout, /qqq process run hello -v greeting=hi/)
const r = await qqq(['widget', 'get', 'runIt', '--run', '-O', 'table'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /Reply: +hi there/)
assert.equal(stub.requests.find((q) => q.path === '/processes/hello/init')!.values.greeting, 'hi')
})
test('a WIDGET-adorned filter field takes the filter grammar and writes its QueryFilter JSON', async () => {
const r = await qqq(['process', 'run', 'setup', '-v', 'queryFilterJson=state = IL AND total > 10', '--quiet'])
assert.equal(r.code, 0, r.stderr)
const sent = JSON.parse(String(stub.requests.find((q) => /\/step\/pick$/.test(q.path))!.values.queryFilterJson))
assert.deepEqual(sent.criteria.map((c: J) => c.fieldName), ['state', 'total'])
assert.equal(sent.booleanOperator, 'AND')
})
})
@@ -0,0 +1,60 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Developer tools against a stub (the sample has no application APIs and no script tests): api-call, script test. */
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
import { BASE_ROUTES, StubResponse, startStub, type Stub } from './stub.js'
describe('developer tools against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
before(async () => {
box = sandbox()
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: { order: { name: 'order', label: 'Order' } }, processes: { testScript: { name: 'testScript' } }, apps: {} })],
[/^GET \/table\/order\/apis$/, () => ({ apis: [{ name: 'public', label: 'Public API', path: '/api/', versions: [{ version: '2026.Q1' }] }] })],
[/^GET \/api\/2026\.Q1\/order\/7$/, (r) => ({ id: 7, auth: String(r.headers.authorization ?? '') })],
[/^POST \/api\/2026\.Q1\/order\/query$/, (r) => new StubResponse(400, { error: 'bad filter', got: r.json })],
[/^GET \/table\/order\/7\/developer$/, () => ({ associatedScripts: [{ associatedScript: { fieldName: 'checkScriptId' }, script: { values: { id: 3, name: 'check', currentScriptRevisionId: 30 } }, scriptRevisions: [{ values: { id: 30, sequenceNo: 2, apiName: 'public', apiVersion: '2026.Q1' } }], testInputFields: [{ name: 'orderId', label: 'Order', isRequired: true }], testOutputFields: [{ name: 'ok', label: 'Ok' }] }] })],
[/^POST \/table\/scriptRevisionFile\/query$/, () => ({ records: [{ values: { fileName: 'main.js', contents: 'return true' } }] })],
[/^POST \/processes\/testScript\/init$/, (r) => ({ type: 'COMPLETE', processUUID: 't', values: { ok: r.values.orderId === '7' ? 'yes' : 'no', outputObject: { checked: r.values['fileContents:main.js'] }, scriptLogLines: [{ values: { timestamp: '2026-10-31T00:00:00Z', text: 'ran' } }] } })],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('api-call sends the request with the session and prints the answer', async () => {
const r = await qqq(['table', 'api-call', 'order', 'public', '2026.Q1', 'GET', 'order/7', '-O', 'json'])
assert.equal(r.code, 0, r.stderr)
assert.deepEqual(JSON.parse(r.stdout), { id: 7, auth: 'Bearer t' })
assert.match(r.stderr, /GET \/api\/2026\.Q1\/order\/7: HTTP 200/)
const bad = await qqq(['table', 'api-call', 'order', 'public', '2026.Q1', 'POST', 'order/query', '-b', '{"filter":1}', '-O', 'json'])
assert.equal(bad.code, 6)
assert.deepEqual(JSON.parse(bad.stdout), { error: 'bad filter', got: { filter: 1 } })
const unknown = await qqq(['table', 'api-call', 'order', 'nope', '1', 'GET'])
assert.match(unknown.stderr, /no application API "nope" \(APIs: public\)/)
})
test('script test runs the current revision with the test inputs', async () => {
const missing = await qqq(['script', 'test', 'order', '7', 'checkScriptId'])
assert.equal(missing.code, 2)
assert.match(missing.stderr, /-v orderId=…/)
const r = await qqq(['script', 'test', 'order', '7', 'checkScriptId', '-v', 'orderId=7', '-O', 'table'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /output +\{"checked":"return true"\}/)
assert.match(r.stdout, /Ok +yes/)
assert.match(r.stdout, /log: 2026-10-31T00:00:00Z ran/)
const init = stub.requests.find((q) => q.path === '/processes/testScript/init')!
assert.equal(init.values.scriptId, '3')
assert.equal(init.values.apiName, 'public')
assert.equal(init.values.fileNames, 'main.js')
})
})
@@ -0,0 +1,192 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The gaps an enterprise sweep (qqq-all with the full QBit set) found: extension results in table
* output, the -o convention, ESB trigger names, login moving a profile, and the unadvertised
* health endpoint. A small fake enterprise server answers the extension routes; the table flag
* tests use the shared fake server.
*/
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { join } from 'node:path'
import { after, before, describe, test } from 'node:test'
import { TEST_SOURCE, runCli, sandbox, startFakeServer, type CliResult, type FakeServer } from './harness.js'
type J = Record<string, unknown>
const GOLDEN = join(TEST_SOURCE, 'golden')
function golden(name: string, result: CliResult, url: string): void {
const actual = `${result.stdout.split(url).join('http://fake')}--- stderr ---\n${result.stderr.split(url).join('http://fake')}--- exit ${result.code}\n`.replace(/\r\n/g, '\n')
const file = join(GOLDEN, `${name}.txt`)
if (process.env.UPDATE_GOLDEN === '1' || !existsSync(file)) {
mkdirSync(GOLDEN, { recursive: true })
writeFileSync(file, actual)
if (process.env.UPDATE_GOLDEN !== '1') assert.fail(`wrote missing golden ${name}.txt; check it and run again`)
return
}
assert.equal(actual, readFileSync(file, 'utf8'), `golden ${name}.txt (UPDATE_GOLDEN=1 rewrites it)`)
}
const INBOX = {
items: [
{ id: 7, notificationId: 7, title: 'Send Test Event To Webhook failed', body: 'No records were selected.', severity: 'ERROR', category: 'JOB', source: 'processes', href: '/app/webhook', deliveredAt: '2026-11-01T11:08:07Z', age: '4 min', unread: true, sticky: false, actions: [{ label: 'Mark read', name: 'read' }, { label: 'Dismiss', name: 'dismiss' }] },
{ id: 6, notificationId: 6, title: 'Calculate Order Total (JavaScript) failed', body: 'Supply quantity 1..1000', severity: 'ERROR', category: 'JOB', source: 'processes', href: null, deliveredAt: '2026-11-01T11:05:29Z', age: '7 min', unread: true, sticky: false, actions: [] },
],
total: 2,
offset: 0,
limit: 25,
}
interface Enterprise {
url: string
requests: Array<{ path: string; query: J }>
health: boolean
close(): Promise<void>
}
/** A fake enterprise server: alerts and esb installed, health served at /health but not listed. */
async function startEnterprise(): Promise<Enterprise> {
const state: Enterprise = { url: '', requests: [], health: true, close: async () => {} }
const server: Server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://fake')
state.requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams) })
const send = (status: number, body: unknown, headers: Record<string, string> = {}) => {
res.writeHead(status, { 'Content-Type': 'application/json', ...headers })
res.end(JSON.stringify(body))
}
const path = url.pathname
if (path === '/health') return state.health ? send(200, { status: 'UP', timestamp: 1793531653.5, checks: { memory: { status: 'UP', durationMs: 0, details: { usedPercent: '1.5' } }, slack: { status: 'UNKNOWN', durationMs: 1 } } }) : send(404, { message: 'no' })
if (path === '/qqq/v1/version') return send(200, { apiVersion: '1.9.0', features: ['metaData'], extensions: [{ name: 'esb', version: '1.1.0', pathPrefix: '/esb' }, { name: 'alerts', version: '1.2.0', pathPrefix: '/alerts' }] })
if (path === '/qqq/v1/metaData/authentication') return send(200, { type: 'MOCK', values: {} })
if (path === '/qqq/v1/manageSession') return send(200, { uuid: 'session-1', values: { user: { name: 'Admin' } } })
if (path === '/qqq/v1/alerts/inbox') return send(200, INBOX)
if (path === '/qqq/v1/esb/overview') return send(200, { providers: [], destinations: [{ name: 'orderEvents', type: 'TOPIC', triggers: [{ name: 'syncOrder.orderEvents', processName: 'syncOrder' }] }] })
if (path === '/qqq/v1/esb/messages/orderEvents') {
const trigger = url.searchParams.get('trigger')
if (!trigger) return send(400, { code: 'BAD_REQUEST', message: "A topic's messages are kept on each of its triggers' subscriptions: name one of the triggers of orderEvents in the trigger parameter." })
if (trigger !== 'syncOrder.orderEvents') return send(404, { code: 'NOT_FOUND', message: `Trigger ${trigger} was not found on orderEvents.` })
return send(200, { messages: [{ messageId: 'm-1', timestamp: '2026-11-01T11:00:00Z', deliveryCount: 1, event: 'INSERT', rawBody: '{"id":1}' }], hasMore: false })
}
return send(404, { code: 'NOT_FOUND', message: `no route ${path}` })
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
state.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
state.close = () => new Promise<void>((resolve) => {
server.closeAllConnections()
server.close(() => resolve())
})
return state
}
describe('enterprise gaps', { concurrency: true }, () => {
let ent: Enterprise
let fake: FakeServer
let box: ReturnType<typeof sandbox>
before(async () => {
ent = await startEnterprise()
fake = await startFakeServer()
box = sandbox()
})
after(async () => {
await ent.close()
await fake.close()
box.dispose()
})
const onEnt = (args: string[]) => runCli(['--server', ent.url, ...args], { QQQ_TOKEN: 'test-token', ...box.env })
const onFake = (args: string[]) => runCli(['--server', fake.url, ...args], { QQQ_TOKEN: 'test-token', ...box.env })
const lastBody = (path: RegExp) => JSON.parse([...fake.requests].reverse().find((r) => r.method === 'POST' && path.test(r.path))!.body) as { filter?: { orderBys?: unknown } }
test('an extension list prints as a sub-table with default columns; --columns chooses; --json is unchanged', async () => {
golden('ext-alerts-inbox', await onEnt(['alerts', 'get-inbox', '-O', 'table']), ent.url)
golden('ext-alerts-inbox-columns', await onEnt(['alerts', 'get-inbox', '-O', 'table', '--columns', 'id,title,actions']), ent.url)
const json = await onEnt(['alerts', 'get-inbox', '--json'])
assert.deepEqual(JSON.parse(json.stdout), INBOX)
})
test('esb messages: a topic without --trigger lists the triggers; a process name finds its trigger', async () => {
const none = await onEnt(['esb', 'messages', 'orderEvents'])
assert.equal(none.code, 2, none.stderr)
assert.match(none.stderr, /orderEvents is a topic: .* pass --trigger with one of: syncOrder\.orderEvents \(process syncOrder\)/)
const byProcess = await onEnt(['esb', 'messages', 'orderEvents', '--trigger', 'syncOrder', '--json'])
assert.equal(byProcess.code, 0, byProcess.stderr)
assert.match(byProcess.stderr, /trigger syncOrder\.orderEvents \(of process syncOrder\)/)
assert.equal((JSON.parse(byProcess.stdout) as { messages: unknown[] }).messages.length, 1)
const wrong = await onEnt(['esb', 'messages', 'orderEvents', '--trigger', 'nope'])
assert.equal(wrong.code, 2)
assert.match(wrong.stderr, /No trigger nope on orderEvents; its triggers: syncOrder\.orderEvents/)
})
test('health get-summary falls back to /health when the extension is not advertised, with a note', async () => {
const result = await onEnt(['health', 'get-summary', '-O', 'table'])
assert.equal(result.code, 0, result.stderr)
assert.match(result.stderr, /does not advertise the health extension/)
assert.match(result.stdout, /^status +UP$/m)
assert.match(result.stdout, /^components \(2\)\nname +status +durationMs +details$/m)
assert.match(result.stdout, /^advertised +false$/m)
ent.health = false
try {
const absent = await onEnt(['health', 'get-summary'])
assert.equal(absent.code, 5)
assert.match(absent.stderr, /getHealthSummary belongs to extension health, which is not installed/)
} finally {
ent.health = true
}
})
test('-o is the output file of table export; --order sorts', async () => {
const file = join(box.env.HOME!, 'people.csv')
const result = await onFake(['table', 'export', 'person', '-o', file, '--order', '-id'])
assert.equal(result.code, 0, result.stderr)
assert.equal(readFileSync(file, 'utf8'), 'id,firstName\n1,Avery\n2,Blair\n')
assert.deepEqual(lastBody(/\/table\/person\/export$/).filter?.orderBys, [{ fieldName: 'id', isAscending: false }])
})
test('-o still sorts table query for one release, with a deprecation note; a path-like sort field warns', async () => {
const old = await onFake(['table', 'query', 'person', '-o', '-id', '--limit', '1', '--json'])
assert.equal(old.code, 0, old.stderr)
assert.match(old.stderr, /-o for the sort order is deprecated; use --order/)
assert.deepEqual(lastBody(/\/table\/person\/query$/).filter?.orderBys, [{ fieldName: 'id', isAscending: false }])
const pathy = await onFake(['table', 'query', 'person', '--order', 'out.csv', '--print-filter'])
assert.match(pathy.stderr, /the sort field "out\.csv" looks like a file path/)
const help = await onFake(['table', 'query', '--help'])
assert.doesNotMatch(help.stdout, /-o[, ]/)
assert.match(help.stdout, /--order <field\.\.\.>/)
const exportHelp = await onFake(['table', 'export', '--help'])
assert.match(exportHelp.stdout, /-o, --out <file>/)
})
test('login --server for an existing profile warns and needs --yes; --profile <new> creates one', async () => {
const home = sandbox()
try {
const env: Record<string, string> = { ...home.env, QQQ_PROFILE: 'ent' }
mkdirSync(env.QQQ_CONFIG_DIR!, { recursive: true })
const config = join(env.QQQ_CONFIG_DIR!, 'config.json')
writeFileSync(config, JSON.stringify({ profiles: { ent: { server: 'http://127.0.0.1:9' } }, defaultProfile: 'ent' }))
const refused = await runCli(['login', '--server', ent.url], env)
assert.equal(refused.code, 2, refused.stderr)
assert.match(refused.stderr, /warning: profile "ent" \(QQQ_PROFILE=ent\) points at http:\/\/127\.0\.0\.1:9; signing in to .* would point it there instead/)
assert.match(refused.stderr, /nothing was changed\. Pass --yes .* or --profile <new-name>/)
assert.equal((JSON.parse(readFileSync(config, 'utf8')) as { profiles: Record<string, { server: string }> }).profiles.ent!.server, 'http://127.0.0.1:9')
const created = await runCli(['login', '--server', ent.url, '--profile', 'enterprise'], env)
assert.equal(created.code, 0, created.stderr)
let profiles = (JSON.parse(readFileSync(config, 'utf8')) as { profiles: Record<string, { server: string }> }).profiles
assert.equal(profiles.ent!.server, 'http://127.0.0.1:9')
assert.equal(profiles.enterprise!.server, ent.url)
const moved = await runCli(['login', '--server', ent.url, '--yes'], env)
assert.equal(moved.code, 0, moved.stderr)
profiles = (JSON.parse(readFileSync(config, 'utf8')) as { profiles: Record<string, { server: string }> }).profiles
assert.equal(profiles.ent!.server, ent.url)
} finally {
home.dispose()
}
})
})
+69
View File
@@ -0,0 +1,69 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** A reader that leaves early (`qqq table query person | head -1`) is not an error: quiet exit 0. */
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { after, before, describe, test } from 'node:test'
import { exitOnBrokenPipe, isBrokenPipe } from '../context.js'
import { CLI, sandbox } from './harness.js'
import { BASE_ROUTES, startStub, type Stub } from './stub.js'
describe('broken pipes', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
before(async () => {
box = sandbox()
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
assert.ok(!isBrokenPipe(new Error('other')))
const listeners: Array<(e: Error) => void> = []
const exits: number[] = []
let uncaught: ((e: Error) => void) | undefined
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0, 0])
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
})
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
if (closeAt === 'before-any-write') child.stdout.destroy()
else child.stdout.once('data', () => child.stdout.destroy())
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
return { code, stderr }
}
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('before-any-write')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('first-chunk')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
})
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Command } from 'commander'
import { ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, operationsOf } from '@qrun-io/qqq-core'
import { EXIT, describeError } from '../context.js'
import { nounOf, registerExtensions, subcommandNames } from '../commands/extensions.js'
import { presenceLine } from '../commands/collab.js'
test('every operation of every extension is a uniquely named subcommand of its noun', () => {
const program = new Command()
registerExtensions(program, () => { throw new Error('not called') })
for (const e of KNOWN_EXTENSIONS) {
const names = subcommandNames(e.name, e.pathPrefix, operationsOf(e.name))
assert.equal(names.size, operationsOf(e.name).length, `${e.name}: one name per operation`)
const noun = program.commands.find((c) => c.name() === nounOf(e.name))!
assert.ok(noun, `noun for ${e.name}`)
for (const name of names.keys()) assert.ok(noun.commands.some((c) => c.name() === name), `${nounOf(e.name)} ${name}`)
}
const esb = subcommandNames('esb', '/esb', operationsOf('esb'))
assert.deepEqual([...esb.keys()].sort(), ['get-dead-letters', 'get-messages', 'get-overview', 'get-process', 'get-table'])
const collab = subcommandNames('collaboration', '/qbit/collaboration', operationsOf('collaboration'))
assert.equal(collab.get('get-records-comments')?.id, 'getQbitCollaborationRecordsByTableByPkComments')
assert.equal(collab.get('get-people-by-user-id')?.id, 'getQbitCollaborationPeopleByUserId')
const collabNoun = program.commands.find((c) => c.name() === 'collab')!
assert.deepEqual(collabNoun.aliases(), ['collaboration'])
for (const verb of ['presence', 'comments', 'favorites', 'recents', 'follow', 'unfollow', 'my-work', 'people']) assert.ok(collabNoun.commands.some((c) => c.name() === verb), verb)
})
test('a missing extension maps to exit 5 with the installed list', () => {
const error = new ExtensionNotInstalledError('collaboration', ExtensionRegistry.fromVersion({ extensions: [{ name: 'esb', version: '1.1.0' }] }).installed)
assert.deepEqual(describeError(error), { code: EXIT.notFound, text: 'Error: extension collaboration is not installed on this server (installed: esb 1.1.0)' })
})
test('presence lines say who is there and since when', () => {
const since = new Date(2026, 9, 31, 17, 50, 1)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
})
+1
View File
@@ -0,0 +1 @@
{"record":{"values":{"id":1,"createDate":"2026-10-31T22:33:01.449733Z","modifyDate":"2026-10-31T23:10:51.963379Z","firstName":"Avery","lastName":"Sample","birthDate":"1990-01-15","email":"avery@example.invalid","isEmployed":true,"annualSalary":75003.50,"daysWorked":42},"displayValues":{"firstName":"Avery","lastName":"Sample","annualSalary":"$75,003.50","modifyDate":"2026-10-31T23:10:51.963379Z","daysWorked":"42","id":"1","birthDate":"1990-01-15","isEmployed":"Yes","email":"avery@example.invalid","createDate":"2026-10-31T22:33:01.449733Z"},"recordLabel":"Avery Sample","tableName":"person"}}
@@ -0,0 +1 @@
{"record":{"values":{"id":1,"label":"USA address","countryId":1,"stateId":1,"cityId":1},"displayValues":{"countryId":"USA","stateId":"Illinois","cityId":"Chester","id":"1","label":"USA address"},"recordLabel":"USA address","tableName":"placeAddress"}}
+1
View File
@@ -0,0 +1 @@
{"metaDataGeneration":0,"apps":{"greetingsApp":{"label":"Greetings App","widgets":["PersonsByCreateDateBarChart","QuickSightChartRenderer"],"sections":[{"role":"PRIMARY","label":"Greetings App","processes":["greet","greetInteractive"],"tables":["person","pet","petNote","city"],"icon":{"name":"badge"},"collapsedByDefault":false,"name":"greetingsApp"}],"icon":{"name":"emoji_people"},"childMap":{"greetInteractive":{"label":"Greet Interactive","icon":{"name":"waving_hand"},"name":"greetInteractive","type":"PROCESS"},"city":{"label":"Cities","icon":{"name":"location_city"},"name":"city","type":"TABLE"},"person":{"label":"Person","icon":{"semanticName":"person","name":"person"},"name":"person","type":"TABLE"},"greet":{"label":"Greet People","icon":{"name":"emoji_people"},"name":"greet","type":"PROCESS"},"petNote":{"label":"Pet Note","icon":{"name":"notes"},"name":"petNote","type":"TABLE"},"pet":{"label":"Pet","icon":{"name":"pets"},"name":"pet","type":"TABLE"}},"name":"greetingsApp","children":[{"label":"Greet People","icon":{"name":"emoji_people"},"name":"greet","type":"PROCESS"},{"label":"Person","icon":{"semanticName":"person","name":"person"},"name":"person","type":"TABLE"},{"label":"Pet","icon":{"name":"pets"},"name":"pet","type":"TABLE"},{"label":"Pet Note","icon":{"name":"notes"},"name":"petNote","type":"TABLE"},{"label":"Cities","icon":{"name":"location_city"},"name":"city","type":"TABLE"},{"label":"Greet Interactive","icon":{"name":"waving_hand"},"name":"greetInteractive","type":"PROCESS"}]},"places":{"label":"Places","sections":[{"role":"PRIMARY","label":"Places","processes":["placeAddressPick"],"tables":["placeAddress","country","countryState","stateCity"],"icon":{"name":"badge"},"collapsedByDefault":false,"name":"places"}],"icon":{"name":"location_city"},"childMap":{"country":{"label":"Countries","name":"country","type":"TABLE"},"placeAddressPick":{"label":"Pick a place","name":"placeAddressPick","type":"PROCESS"},"placeAddress":{"label":"Address book","name":"placeAddress","type":"TABLE"},"stateCity":{"label":"Cities by state","name":"stateCity","type":"TABLE"},"countryState":{"label":"States and provinces","name":"countryState","type":"TABLE"}},"name":"places","children":[{"label":"Address book","name":"placeAddress","type":"TABLE"},{"label":"Countries","name":"country","type":"TABLE"},{"label":"States and provinces","name":"countryState","type":"TABLE"},{"label":"Cities by state","name":"stateCity","type":"TABLE"},{"label":"Pick a place","name":"placeAddressPick","type":"PROCESS"}]},"miscellaneous":{"label":"Miscellaneous","sections":[{"role":"PRIMARY","label":"Miscellaneous","processes":["simpleSleep","sleepInteractive","simpleThrow"],"tables":["carrier","fieldLab","petSpecies"],"icon":{"name":"badge"},"collapsedByDefault":false,"name":"miscellaneous"}],"icon":{"name":"stars"},"childMap":{"carrier":{"label":"Carrier","icon":{"name":"local_shipping"},"name":"carrier","type":"TABLE"},"fieldLab":{"label":"Field Lab","icon":{"name":"science"},"name":"fieldLab","type":"TABLE"},"simpleSleep":{"label":"Simple Sleep","name":"simpleSleep","type":"PROCESS"},"petSpecies":{"label":"Pet Species","icon":{"name":"pets"},"name":"petSpecies","type":"TABLE"},"simpleThrow":{"label":"Simple Throw","name":"simpleThrow","type":"PROCESS"},"sleepInteractive":{"label":"Sleep Interactive","name":"sleepInteractive","type":"PROCESS"}},"name":"miscellaneous","children":[{"label":"Carrier","icon":{"name":"local_shipping"},"name":"carrier","type":"TABLE"},{"label":"Field Lab","icon":{"name":"science"},"name":"fieldLab","type":"TABLE"},{"label":"Pet Species","icon":{"name":"pets"},"name":"petSpecies","type":"TABLE"},{"label":"Simple Sleep","name":"simpleSleep","type":"PROCESS"},{"label":"Sleep Interactive","name":"sleepInteractive","type":"PROCESS"},{"label":"Simple Throw","name":"simpleThrow","type":"PROCESS"}]},"peopleApp":{"label":"People App","sections":[{"role":"PRIMARY","label":"People App","processes":["clonePeople"],"icon":{"name":"badge"},"collapsedByDefault":false,"name":"peopleApp"}],"icon":{"semanticName":"person","name":"person"},"childMap":{"greetingsApp":{"label":"Greetings App","icon":{"name":"emoji_people"},"name":"greetingsApp","type":"APP"},"clonePeople":{"label":"Clone People","icon":{"name":"content_copy"},"name":"clonePeople","type":"PROCESS"}},"name":"peopleApp","children":[{"label":"Greetings App","icon":{"name":"emoji_people"},"name":"greetingsApp","type":"APP"},{"label":"Clone People","icon":{"name":"content_copy"},"name":"clonePeople","type":"PROCESS"}]},"sales":{"label":"Sales","sections":[{"role":"PRIMARY","label":"Sales","tables":["sales_order","customer","sku","tag"],"icon":{"name":"badge"},"collapsedByDefault":false,"name":"sales"}],"icon":{"name":"storefront"},"childMap":{"sales_order":{"label":"Sales Order","icon":{"name":"receipt_long"},"name":"sales_order","type":"TABLE"},"tag":{"label":"Tag","icon":{"name":"sell"},"name":"tag","type":"TABLE"},"sku":{"label":"SKU","icon":{"name":"inventory_2"Line truncated
@@ -0,0 +1 @@
{"frontendSteps":[{"components":[{"values":{"includeFieldNames":["sleepMillis"]},"type":"EDIT_FORM"},{"values":{"includeFieldNames":["outputMessage"]},"type":"VIEW_FORM"}],"label":"Screen 0","formFields":[{"label":"Sleep duration (milliseconds)","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"sleepMillis","type":"INTEGER","defaultValue":"1000"},{"label":"Output Message","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"outputMessage","type":"STRING"}],"name":"screen0"},{"components":[{"type":"VIEW_FORM"}],"label":"Screen 1","formFields":[{"label":"Output Message","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"outputMessage","type":"STRING"}],"name":"screen1"}],"label":"Sleep Interactive","stepFlow":"LINEAR","isHidden":false,"hasPermission":true,"durableJobEligible":false,"durableJobAlways":false,"name":"sleepInteractive"}
+1
View File
@@ -0,0 +1 @@
{"records":[{"values":{"id":1,"createDate":"2026-10-31T22:33:01.449733Z","modifyDate":"2026-10-31T23:10:51.963379Z","firstName":"Avery","lastName":"Sample","birthDate":"1990-01-15","email":"avery@example.invalid","isEmployed":true,"annualSalary":75003.50,"daysWorked":42},"displayValues":{"firstName":"Avery","lastName":"Sample","annualSalary":"$75,003.50","modifyDate":"2026-10-31T23:10:51.963379Z","daysWorked":"42","id":"1","birthDate":"1990-01-15","isEmployed":"Yes","email":"avery@example.invalid","createDate":"2026-10-31T22:33:01.449733Z"},"recordLabel":"Avery Sample","tableName":"person"},{"values":{"id":2,"createDate":"2026-10-31T22:33:01.450530Z","modifyDate":"2026-10-31T23:10:51.963379Z","firstName":"Blair","lastName":"Sample","birthDate":"1991-02-16","email":"blair@example.invalid","isEmployed":true,"annualSalary":150000.00,"daysWorked":42},"displayValues":{"firstName":"Blair","lastName":"Sample","annualSalary":"$150,000.00","modifyDate":"2026-10-31T23:10:51.963379Z","daysWorked":"42","id":"2","birthDate":"1991-02-16","isEmployed":"Yes","email":"blair@example.invalid","createDate":"2026-10-31T22:33:01.450530Z"},"recordLabel":"Blair Sample","tableName":"person"},{"values":{"id":3,"createDate":"2026-10-31T22:33:01.450935Z","modifyDate":"2026-10-31T22:33:01.450935Z","firstName":"Casey","lastName":"Sample","birthDate":"1992-03-17","email":"casey@example.invalid","isEmployed":true,"annualSalary":300000.00,"daysWorked":100100},"displayValues":{"firstName":"Casey","lastName":"Sample","annualSalary":"$300,000.00","modifyDate":"2026-10-31T22:33:01.450935Z","daysWorked":"100,100","id":"3","birthDate":"1992-03-17","isEmployed":"Yes","email":"casey@example.invalid","createDate":"2026-10-31T22:33:01.450935Z"},"recordLabel":"Casey Sample","tableName":"person"}]}
+1
View File
@@ -0,0 +1 @@
{"clientCapabilities":{"savedViews":{"available":false},"audits":{"available":false,"level":"NONE"},"bulkInsert":{"available":true,"processName":"person.bulkInsert"},"bulkEdit":{"available":true,"processName":"person.bulkEdit"},"bulkEditWithFile":{"available":true,"processName":"person.bulkEditWithFile"},"bulkDelete":{"available":true,"processName":"person.bulkDelete"}},"associations":[{"associatedTableName":"pet","join":{"name":"personJoinPet","type":"ONE_TO_MANY","leftTable":"person","rightTable":"pet","joinOns":[{"leftField":"id","rightField":"personId"}]},"name":"pets"}],"sections":[{"label":"Identity","tier":"T1","icon":{"name":"badge"},"fieldNames":["id","firstName","lastName"],"name":"identity","hidden":false},{"label":"Basic Info","tier":"T2","icon":{"name":"dataset"},"fieldNames":["email","birthDate"],"name":"basicInfo","hidden":false},{"label":"Employment Info","tier":"T2","icon":{"name":"work"},"fieldNames":["isEmployed","annualSalary","daysWorked"],"name":"employmentInfo","hidden":false},{"label":"Dates","tier":"T3","icon":{"name":"calendar_month"},"fieldNames":["createDate","modifyDate"],"name":"dates","hidden":false}],"usesVariants":false,"primaryKeyField":"id","hasRestrictedFields":false,"fields":{"firstName":{"label":"First Name","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"firstName","type":"STRING"},"lastName":{"label":"Last Name","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"lastName","type":"STRING"},"annualSalary":{"label":"Annual Salary","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"$%,.2f","isHidden":false,"name":"annualSalary","type":"DECIMAL"},"modifyDate":{"label":"Modify Date","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"modifyDate","type":"DATE_TIME"},"daysWorked":{"label":"Days Worked","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%,d","isHidden":false,"name":"daysWorked","type":"INTEGER"},"id":{"label":"Id","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"id","type":"INTEGER"},"birthDate":{"label":"Birth Date","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"birthDate","type":"DATE"},"isEmployed":{"label":"Is Employed","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"isEmployed","type":"BOOLEAN"},"email":{"label":"Email","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"email","type":"STRING"},"createDate":{"label":"Create Date","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"createDate","type":"DATE_TIME"}},"label":"Person","metaDataGeneration":0,"icon":{"semanticName":"person","name":"person"},"isHidden":false,"capabilities":["TABLE_COUNT","TABLE_GET","TABLE_QUERY","TABLE_INSERT","TABLE_UPDATE","TABLE_DELETE","TABLE_EXPORT"],"readPermission":true,"insertPermission":true,"editPermission":true,"deletePermission":true,"name":"person"}
+1
View File
@@ -0,0 +1 @@
{"clientCapabilities":{"savedViews":{"available":false},"audits":{"available":false,"level":"NONE"},"bulkInsert":{"available":true,"processName":"pet.bulkInsert"},"bulkEdit":{"available":true,"processName":"pet.bulkEdit"},"bulkEditWithFile":{"available":true,"processName":"pet.bulkEditWithFile"},"bulkDelete":{"available":true,"processName":"pet.bulkDelete"}},"associations":[{"associatedTableName":"petNote","join":{"name":"petJoinNote","type":"ONE_TO_MANY","leftTable":"pet","rightTable":"petNote","joinOns":[{"leftField":"id","rightField":"petId"}]},"name":"notes"}],"sections":[{"label":"Identity","tier":"T1","icon":{"name":"badge"},"fieldNames":["id","name"],"name":"identity","hidden":false},{"label":"Basic Info","tier":"T2","icon":{"name":"dataset"},"fieldNames":["personId","speciesId","birthDate"],"name":"basicInfo","hidden":false},{"label":"Dates","tier":"T3","icon":{"name":"calendar_month"},"fieldNames":["createDate","modifyDate"],"name":"dates","hidden":false}],"usesVariants":false,"primaryKeyField":"id","hasRestrictedFields":false,"fields":{"modifyDate":{"label":"Modify Date","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"modifyDate","type":"DATE_TIME"},"name":{"label":"Name","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"name","type":"STRING"},"personId":{"possibleValueSourceName":"person","label":"Person","isHeavy":false,"isRequired":true,"isEditable":true,"adornments":[{"values":{"toRecordFromTable":"person"},"type":"LINK"}],"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"personId","type":"INTEGER"},"speciesId":{"possibleValueSourceName":"petSpecies","label":"Species","isHeavy":false,"isRequired":true,"isEditable":true,"displayFormat":"%s","isHidden":false,"validation":{"required":true},"name":"speciesId","type":"INTEGER"},"id":{"label":"Id","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"id","type":"INTEGER"},"birthDate":{"label":"Birth Date","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"birthDate","type":"DATE"},"createDate":{"label":"Create Date","isHeavy":false,"isRequired":false,"isEditable":false,"displayFormat":"%s","isHidden":false,"name":"createDate","type":"DATE_TIME"}},"label":"Pet","metaDataGeneration":0,"icon":{"name":"pets"},"isHidden":false,"capabilities":["TABLE_COUNT","TABLE_GET","TABLE_QUERY","TABLE_INSERT","TABLE_UPDATE","TABLE_DELETE","TABLE_EXPORT"],"readPermission":true,"insertPermission":true,"editPermission":true,"deletePermission":true,"name":"pet"}
@@ -0,0 +1 @@
{"clientCapabilities":{"savedViews":{"available":false},"audits":{"available":false,"level":"NONE"},"bulkInsert":{"available":true,"processName":"placeAddress.bulkInsert"},"bulkEdit":{"available":true,"processName":"placeAddress.bulkEdit"},"bulkEditWithFile":{"available":true,"processName":"placeAddress.bulkEditWithFile"},"bulkDelete":{"available":true,"processName":"placeAddress.bulkDelete"}},"sections":[{"label":"Identity","tier":"T1","icon":{"name":"badge"},"fieldNames":["id","label"],"name":"identity","hidden":false},{"label":"Other Fields","tier":"T2","icon":{"name":"dataset"},"fieldNames":["countryId","stateId","cityId"],"name":"otherFields","hidden":false}],"usesVariants":false,"primaryKeyField":"id","hasRestrictedFields":false,"fields":{"stateId":{"possibleValueSourceName":"countryState","possibleValueSourceDependsOn":["countryId"],"label":"State / province","isHeavy":false,"isRequired":false,"isEditable":true,"adornments":[{"values":{"toRecordFromTable":"countryState"},"type":"LINK"}],"possibleValueSourceDependencyBehavior":"DISABLE_UNTIL_SET","displayFormat":"%s","isHidden":false,"name":"stateId","type":"INTEGER"},"id":{"label":"Id","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"id","type":"INTEGER"},"label":{"label":"Label","isHeavy":false,"isRequired":false,"isEditable":true,"displayFormat":"%s","isHidden":false,"name":"label","type":"STRING"},"cityId":{"possibleValueSourceName":"stateCity","possibleValueSourceDependsOn":["stateId"],"label":"City","isHeavy":false,"isRequired":false,"isEditable":true,"adornments":[{"values":{"toRecordFromTable":"stateCity"},"type":"LINK"}],"possibleValueSourceDependencyBehavior":"DISABLE_UNTIL_SET","displayFormat":"%s","isHidden":false,"name":"cityId","type":"INTEGER"},"countryId":{"possibleValueSourceName":"country","label":"Country","isHeavy":false,"isRequired":false,"isEditable":true,"adornments":[{"values":{"toRecordFromTable":"country"},"type":"LINK"}],"displayFormat":"%s","isHidden":false,"name":"countryId","type":"INTEGER"}},"label":"Address book","metaDataGeneration":0,"isHidden":false,"capabilities":["TABLE_COUNT","TABLE_GET","TABLE_QUERY","TABLE_INSERT","TABLE_UPDATE","TABLE_DELETE","TABLE_EXPORT"],"readPermission":true,"insertPermission":true,"editPermission":true,"deletePermission":true,"name":"placeAddress"}
+1
View File
@@ -0,0 +1 @@
{"apiVersion":"1.8.0","basePath":"/qqq/v1","supportedMajorVersions":[1],"features":["metaData","tables","recordSearch","tableVariants","recordDeveloper","possibleValues","widgets","processes","processCancel","reports","exports","downloads","version","extensions","recordFeatures","realtime","help","preferences","durableJobs","delivery"],"extensions":[{"name":"esb","version":"1.1.0","pathPrefix":"/esb","description":"ESB overview, the ESB of tables and processes, and messages and dead letters on destinations."}],"clientChannels":["next-ui","material-dashboard","assistant","cli","tui","ios","api","token"]}
+1
View File
@@ -0,0 +1 @@
{"title":"Line Chart","description":"Total units have been <strong>increasing</strong> over the last five months.","chartData":{"labels":["January","February","March","April","May"],"datasets":[{"label":"Units","data":[1753,1830,920,1543,1804]}],"dataset":{"label":"Units","data":[1753,1830,920,1543,1804]}},"isCurrency":false,"height":0,"type":"chart"}
-35
View File
@@ -1,35 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatTable, parseCriteria, parseValues, valueText } from '../format.js'
const fields = { id: {}, status: {}, lastName: {} }
test('criteria parse operators, shorthand and lists', () => {
assert.deepEqual(parseCriteria('status EQUALS active', fields), { fieldName: 'status', operator: 'EQUALS', values: ['active'] })
assert.deepEqual(parseCriteria('id >= 10', fields), { fieldName: 'id', operator: 'GREATER_THAN_OR_EQUALS', values: ['10'] })
assert.deepEqual(parseCriteria('id IN 1, 2,3', fields), { fieldName: 'id', operator: 'IN', values: ['1', '2', '3'] })
assert.deepEqual(parseCriteria('lastName ~ van der', fields), { fieldName: 'lastName', operator: 'CONTAINS', values: ['van der'] })
assert.deepEqual(parseCriteria('lastName IS_BLANK', fields), { fieldName: 'lastName', operator: 'IS_BLANK', values: [] })
assert.throws(() => parseCriteria('nope EQUALS 1', fields), /No field nope/)
assert.throws(() => parseCriteria('id ABOUT 1', fields), /Unknown operator/)
})
test('values parse name=value pairs', () => {
assert.deepEqual(parseValues(['a=1', 'b=x=y']), { a: '1', b: 'x=y' })
assert.throws(() => parseValues(['=1']))
})
test('display values win over raw values', () => {
assert.equal(valueText({ values: { s: 1 }, displayValues: { s: 'Active' } }, 's'), 'Active')
assert.equal(valueText({ values: { s: 1 } }, 's'), '1')
assert.equal(valueText({ values: {} }, 's'), '')
})
test('tables align columns', () => {
assert.equal(formatTable([{ a: 'x', b: 'long value' }], ['a', 'b']), 'a b\n- ----------\nx long value')
})
@@ -0,0 +1,3 @@
"Person"
--- stderr ---
--- exit 0
@@ -0,0 +1,3 @@
--- stderr ---
Error: getTableMetaData needs path parameter "tableName" (--path tableName=...)
--- exit 2
@@ -0,0 +1,3 @@
--- stderr ---
Error: --limit must be a whole number of at least 1: many
--- exit 2
@@ -0,0 +1,3 @@
--- stderr ---
Error: extension collaboration is not installed on this server (installed: esb 1.1.0)
--- exit 5
@@ -0,0 +1,3 @@
--- stderr ---
Error: Interactive prompts need a terminal; give values with --set, --values or --input-json.
--- exit 2
+5
View File
@@ -0,0 +1,5 @@
created label
------- -----
Pet 101 Pixel
--- stderr ---
--- exit 0
@@ -0,0 +1,3 @@
--- stderr ---
Error: Delete Pet 7? Pass --yes to confirm without a terminal.
--- exit 2
@@ -0,0 +1,5 @@
deleted
-------
Pet 7
--- stderr ---
--- exit 0
@@ -0,0 +1,6 @@
destination type provider publishers triggers published consumed failed deadLettered lastActivity
------------ ----- -------- ---------- -------- --------- -------- ------ ------------ -----------------------
personEvents TOPIC local 1 0 3 3 0 0 2026-10-31 19:30:00 UTC
(1 destination; 1 provider)
--- stderr ---
--- exit 0
Loaded 100 of 503 files, more files were not shown because too many files have changed in this diff. Show more