Merge branch 'feat/m1-kernel-cli' into feat/m3-tui

This commit is contained in:
james.maes committed 2026-10-31 19:08:45 -05:00
commit 98e426b69d
27 files changed
+1341 -144

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ dist/
*.tgz
.env
.spec-src/
packages/*/LICENSE
+88
View File
@@ -0,0 +1,88 @@
# 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.
## [1.0.0-rc.1] - 2026-10-31
First release candidate of `qqq` 1.0 (M1: the kernel and the CLI). Packages: `@qrun-io/qqq-cli`
1.0.0-rc.1, `@qrun-io/qqq-core` 1.0.0-rc.1 (new), `@qrun-io/qqq-ui-api-client` 1.8.0-rc.2,
`@qrun-io/qqq-ui-api-conformance` 1.6.0-rc.2. Servers: UI API 1.6.0 up to 2.0.
### Changed from 0.1
- 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-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`).
### Added
- **Kernel package `@qrun-io/qqq-core`**: everything that is not a terminal, shared by the CLI and the
coming TUI: output formats with JMESPath `--query`, the filter grammar (shorthand operators, quoting,
AND/OR groups, expression values such as `now(-7d)`, `thisPeriod(month)`, `me()`), 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.
- **Sign-in**: session sign-in for MOCK and anonymous servers; OAuth2 device flow and browser sign-in
with PKCE on a loopback redirect; refresh before expiry; `logout --provider-logout` revokes (RFC 7009)
and opens the end-session page; refused refreshes exit 3. Profiles (`--profile`, `QQQ_PROFILE`,
`config profile`) and the macOS Keychain, Linux Secret Service, Windows DPAPI or a 0600 file.
- **Tables**: `list`, `describe`, `query` (filters, sort, paging, `--search`, `--join` and `table.field`
columns, `--count`, `--print-filter`, saved views with `--view`), `count`, `stats` (server columnStats
or `--local`), `get` (sections, adornments, `--associations`, `--links`, `--follow`, `--key`), `create`,
`copy`, `update`, `delete`, `children`, `export` (csv, tsv, xlsx, json), `download` and `--upload` for
file fields, `variants` and `--variant` everywhere.
- **Forms**: contingent fields (country → state → city) with narrowing possible values, labels or unique
partial labels for ids, local validation with the server's codes, form adjusters, interactive `-i`.
- **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).
- **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), `search`, `tail`, `saved-view`, `audit list`,
`report`, `widget` (charts and tables as text), `job`, `prefs`, `help show`, `help-search`, and
`api <operationId>` for any of the 202 operations.
- **Extensions**: a noun per extension the client document knows (13: 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).
- **Shell completion** for bash, zsh, fish and PowerShell from the local metadata cache; `cache show |
refresh | clear`.
- `qqq doctor`; `config set theme` and `download-dir`; downloads never overwrite a file or leave
`--out-dir`; consistent exit codes (2 usage, 3 auth, 4 unreachable or 502/503/504, 5 not found,
6 validation, 70 server error).
- `qqq --version` prints the build (`node` or the binary's target and commit) and the supported UI API
range.
- **Distribution**: single-file binaries for macOS arm64 and x64, Linux x64 and arm64 (glibc and musl)
and Windows x64 (`npm run binaries`); a curl installer that verifies `SHA256SUMS`; Homebrew and Scoop
drafts; `npm run release`.
### Client and conformance
- `@qrun-io/qqq-ui-api-client` is generated from UI API 1.8.0 merged with 13 extension documents (202
operations, each tagged with its extension), and gains PKCE, revocation and end-session helpers.
- `qqq-dev-idp` gains the authorization code with PKCE (Approve/Deny page), revocation and end-session.
### Fixed
- `npm test` runs on Node 20 (the test glob is expanded by the shell; `node --test` only expands globs
from Node 21).
- 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.
### Known limitations
- Bulk load is not in the CLI yet; jobs need a durable-jobs server; TABLE_BASED sign-in is refused (no
documented native recipe). See `docs/server-findings.md`.
- The binaries are not Developer ID signed or notarized (macOS) nor Authenticode signed (Windows). The
Windows and macOS x64 binaries are cross-compiled and were not run before this candidate.
- The TUI (`qqq ui`) is not part of this release; the binaries say so when asked.
## [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.
+99 -79
View File
@@ -1,71 +1,110 @@
# qqq-cli
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 for [QQQ](https://github.com/QRun-IO/qqq) applications. It talks to a
QQQ server only through the **UI API** (`/qqq/v1`), the API the web UI uses too. Nothing about an
application is built in: apps, tables, fields, processes, widgets and extensions all come from the
server's metadata, so the same 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 is **1.0.0-rc.1**, the first release candidate. It works with servers that serve UI API 1.6.0 up to
2.0 (QQQ 4.1.0-RC.15 or later); `qqq --version` prints the range.
The packages are published to GitHub Packages:
The repository also holds the libraries the CLI is built on, for anyone writing a CLI, TUI or native client:
| Package | Version | What it is |
|---|---|---|
| [`@qrun-io/qqq-cli`](packages/cli) | 1.0.0-rc.1 | `qqq`, the CLI |
| [`@qrun-io/qqq-core`](packages/core) | 1.0.0-rc.1 | 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-rc.2 | a typed TypeScript client generated from the merged UI API document (core plus 13 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-rc.2 | 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) | not released | `qqq ui`, the full-screen terminal UI (OpenTUI on Bun), in development |
## Install
A single-file binary (no Node needed) for macOS (arm64, x64), Linux (x64, arm64; glibc, and musl for
Alpine) or Windows (x64), from the [releases](https://github.com/QRun-IO/qqq-cli/releases):
```sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh # to ~/.local/bin
```
The installer picks the right build and checks it against the release's `SHA256SUMS`
(`--version 1.0.0-rc.1`, `--dir /usr/local/bin`). Homebrew and Scoop packages are drafted in
[`docs/packaging`](docs/packaging/README.md) and land with the release.
Or from npm (GitHub Packages), with Node.js 20 or later:
```sh
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@rc
```
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).
## The CLI
## First sign-in
```console
$ qqq login --server https://admin.example.com
$ qqq login --server https://admin.example.com --profile prod
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.
Signed in to https://admin.example.com as darin@example.com; sign-in kept in the macos-keychain.
$ 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 whoami
$ qqq table query person -w "lastName STARTS_WITH S" -o -id -l 5
```
| Command | UI API operations |
- **OAuth2 and Auth0** servers use the device flow; `--browser` uses the authorization code with PKCE on
a loopback redirect. Tokens are refreshed before they expire; `qqq logout --provider-logout` also
revokes them at the provider. **MOCK** and fully anonymous servers get a server session.
- **Profiles** name servers, as `aws --profile`: `qqq --profile prod …`, `QQQ_PROFILE`, `qqq config profile
list|use|remove`. The first sign-in becomes the default.
- **Sign-ins** live in the OS keyring: macOS Keychain, Secret Service on Linux, DPAPI on Windows, or a
0600 file with `QQQ_KEYRING=file` (headless hosts). `QQQ_TOKEN` overrides everything with a bearer token.
- `qqq doctor` checks the connection, the sign-in and the terminal.
## The grammar
`qqq <noun> <verb> [arguments] [options]`, with global options anywhere.
| 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` |
| `process` | `list`, `describe`, `run` (every step screen, async jobs with progress, Ctrl-C cancels on the server) |
| `apps`, `search`, `tail`, `whoami` | app homes, global search, the live event stream |
| `saved-view`, `audit`, `report`, `widget`, `job`, `prefs`, `help-search` | saved views, audit history, reports, dashboard widgets as text, jobs, preferences, help |
| `api <operationId>` | any of the 202 UI API operations by id: the escape hatch |
| `extensions`, and one noun per extension | `esb`, `collab`, `health`, `alerts`, `assistant`, `sql-workbench`, … gated on what the server has installed |
| `login`, `logout`, `config`, `cache`, `completion`, `doctor` | sign-in, profiles and settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell) |
Add `--json` for machine-readable output. Errors print the server's `message`, `code` and `traceId`.
```sh
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" --fields id,firstName,email --out people.xlsx
qqq process run greetInteractive -r 1,2,3 -v greetingPrefix=Hi
qqq api getTableMetaData --path tableName=person -Q 'fields.*.label'
eval "$(qqq completion zsh)"
```
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.
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. Values are labels and display values from the server;
DATE_TIME values are shown in your zone (`--utc` for raw). 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 audits.
Every request carries `X-QQQ-Client: cli/qqq-cli <version>`, so the server's audits attribute it to the CLI.
[TRY-IT.md](TRY-IT.md) walks through every command against the QQQ sample server.
## Documentation
- [TRY-IT.md](TRY-IT.md): the CLI (and the TUI as it is built) against the local sample server
- [`docs/parity/feature-matrix.md`](docs/parity/feature-matrix.md): CLI status of every row of the web UI's acceptance matrix
- [`docs/extensions.md`](docs/extensions.md): the extension documents merged into the client
- [`docs/server-findings.md`](docs/server-findings.md): UI API issues found while building the clients
- [`docs/review-2026-10-31.md`](docs/review-2026-10-31.md): the hardening review of the CLI and kernel
- [`docs/packaging`](docs/packaging/README.md) and [`docs/ci-lanes.md`](docs/ci-lanes.md): install paths, release and CI
- [`packages/core/README.md`](packages/core/README.md): the kernel's modules
- QQQ's [`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): the API itself
- [CHANGELOG.md](CHANGELOG.md)
## Writing a client
@@ -82,55 +121,36 @@ const page = await unwrap(api.POST('/qqq/v1/table/{tableName}/query', {
}))
```
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.
Paths, parameters, bodies and responses are typed from the document. `@qrun-io/qqq-core` adds the
pieces every client needs above that (filter grammar, forms with contingent fields, the process runner,
record view, output formats); see its README.
## Mock server
`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.
## Conformance suite
`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. To check a server:
```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, authorization code with S256 PKCE on loopback redirects (an Approve / Deny page, or `--auto-approve`), refresh, revocation, end-session 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
```
`qqq-dev-idp` is a local OIDC provider (device grant, PKCE, refresh, revocation, RS256 JWTs) for testing
OAuth2 sign-in against a local QQQ server; TRY-IT.md has the recipe.
## Development
```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 1.4+)
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] and SHA256SUMS (Bun 1.4+); --host, --musl
npm run release # every release check, then the tag and publish commands (prints only)
```
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 to build the binaries
and for the TUI. CI is the qqq-orb `cli` profile (`.qqq-ci.yml`); a tag `v<client version>` publishes the
packages to GitHub Packages (see `docs/ci-lanes.md` for what it does and does not cover).
## License
+197
View File
@@ -0,0 +1,197 @@
# CI lanes for the qqq CLI
## What runs today
`.circleci/config.yml` is the qqq-orb setup stub; `.qqq-ci.yml` selects the orb's `cli` profile (orb 0.8,
read from `qqq-orb` at v0.8.32, identical to `develop`). That profile generates one job,
`qqq-orb/cli_verify_publish`, on **Linux, `cimg/node:22.12`**:
1. `npm ci`
2. `npm run generate`, then `git diff --exit-code -- packages/ui-api-client/src/schema.ts`
3. `npm run build`, `npm test`
4. a Prism smoke: `prism mock` on the merged document, `GET /qqq/v1/version` and `/metaData/authentication`
5. on a tag `v<client version>` with `publish.release: true`: `npm publish --tag rc` of
`@qrun-io/qqq-ui-api-client`, `@qrun-io/qqq-ui-api-conformance` and `@qrun-io/qqq-cli` to GitHub Packages
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, to fix in the orb:
- **`@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
Ready to send as is. Nothing has been opened or pushed; both changes are in `kingsrook/qqq-orb` and
need an orb release (0.8.x patch) before this repository picks them up.
> **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).
>
> **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 (branch `feat/m5-cli-release`).
## 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): 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
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
command: |
./dist/<< parameters.binary >> --version
./dist/<< parameters.binary >> __complete -- ta | grep -q table
./dist/<< parameters.binary >> completion pwsh > /dev/null
QQQ_CONFIG_DIR="$PWD/.smoke" ./dist/<< parameters.binary >> config profile list
- 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
```
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.
+74
View File
@@ -0,0 +1,74 @@
# Packaging the qqq CLI
Every install path ships the same single-file binaries that `npm run binaries`
(`scripts/build-binaries.mjs`) writes to `dist/`, uploaded to a GitHub release tagged `cli-v<version>`
together with `SHA256SUMS`. The npm packages (`@qrun-io/qqq-cli` on GitHub Packages) are the other
route, for machines that already have Node 20+.
| File | What | Where it goes |
|---|---|---|
| `scripts/install.sh` | curl installer: picks `qqq-<os>-<arch>[-musl]`, checks it against `SHA256SUMS`, installs to `~/.local/bin` (`--dir`, `--version`) | served from `main`: `curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh \| sh` |
| `qqq.rb` | Homebrew formula draft (macOS arm64 and x64, Linux arm64 and x64), same shape as `qctl.rb`, with bash, zsh and fish completions | `Formula/qqq.rb` in a QRun-IO tap |
| `qqq.json` | Scoop manifest draft (Windows x64), same shape as qctl's, with `checkver` and `autoupdate` reading `SHA256SUMS` | `bucket/qqq.json` in a QRun-IO Scoop bucket |
## Release steps
1. `node scripts/release.mjs` on a clean tree; it prints the tag and publish commands.
2. Tag `cli-v<version>` and create the GitHub release with `dist/qqq-*` and `dist/SHA256SUMS`
(`gh release create`, printed by step 1).
3. Download that release's `SHA256SUMS` and run `node scripts/update-packaging.mjs <path>`; it writes
the version and the checksums into `qqq.rb` and `qqq.json`. Use the uploaded sums, not a local
rebuild: each build stamps its commit into the binary, so checksums differ between builds.
4. Copy `qqq.rb` to the tap and `qqq.json` to the bucket and open a pull request on each.
## Where the formula and manifest live
QRun-IO has one tap today, [`QRun-IO/homebrew-qctl`](https://github.com/QRun-IO/homebrew-qctl)
(`brew tap QRun-IO/qctl`), and qctl's Scoop manifest lives in `qctl/packaging/scoop`. Two options:
- **Add `qqq` to the existing tap**: `brew install qrun-io/qctl/qqq`. No new repository; the tap name
reads oddly.
- **A general tap and bucket** (`QRun-IO/homebrew-tap`, `QRun-IO/scoop-bucket`) holding both qctl and
qqq: `brew install qrun-io/tap/qqq`, `scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket`.
The qqq-orb already has a `homebrew-tap` profile for such a repository. Recommended before 1.0 final.
## Platform notes
- **macOS**: Bun signs the binaries ad hoc, which is enough for Homebrew and curl installs (neither sets
the quarantine flag). A binary downloaded with a browser is quarantined; `xattr -d com.apple.quarantine qqq`
clears it. Developer ID signing and notarization are not done yet.
- **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 signed (Authenticode, per the KofTwentyTwo
release standard, is a follow-up). Sign-ins use DPAPI through PowerShell. No Windows CI lane runs it
yet; see `docs/ci-lanes.md`.
- Binaries are 85 to 111 MB (the Bun runtime is embedded); about 37 to 53 MB gzipped.
## 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 a second copy of the CLI program that the TUI imports from `@qrun-io/qqq-cli/program`).
Not verified: the Windows and macOS x64 builds with the UI (cross-compiled, never run; Windows Terminal
was never checked even for the spike), and Linux glibc arm64 with the UI.
**Recommendation:** ship one `qqq` binary per platform with the UI bundled (`--with-tui`) as the 1.0
artifact, from the same release, Homebrew formula, Scoop manifest and installer: one download, `qqq ui`
just works, 16 to 18 MB more. The npm package stays CLI only for Node users and says how to get the UI.
Before that: a Windows x64 lane that runs `qqq ui --check` and a scripted session, and point the TUI's
`@qrun-io/qqq-cli/program` import at the source so the program is bundled once. `release.mjs --with-tui`
builds that way today; the default stays CLI only until the decision is made.
+27
View File
@@ -0,0 +1,27 @@
{
"version": "1.0.0-rc.1",
"description": "Command-line client for QQQ applications, built on the QQQ UI API",
"homepage": "https://github.com/QRun-IO/qqq-cli",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0-rc.1/qqq-windows-x64.exe",
"hash": "0000000000000000000000000000000000000000000000000000000000000000"
}
},
"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"
}
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# Draft Homebrew formula for the qqq CLI, in the style of QRun-IO/homebrew-qctl's qctl.rb.
# scripts/update-packaging.mjs fills version and sha256 from dist/SHA256SUMS of a release build;
# copy the result to Formula/qqq.rb in the tap (see docs/packaging/README.md).
class Qqq < Formula
desc "Command-line client for QQQ applications, built on the QQQ UI API"
homepage "https://github.com/QRun-IO/qqq-cli"
version "1.0.0-rc.1"
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 "0000000000000000000000000000000000000000000000000000000000000000"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
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 "0000000000000000000000000000000000000000000000000000000000000000"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
end
def install
binary = Dir["qqq-*"].first
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
test do
assert_match "qqq #{version}", shell_output("#{bin}/qqq --version")
assert_match "table", shell_output("#{bin}/qqq __complete -- ta")
end
end
+17 -8
View File
@@ -2954,21 +2954,24 @@
},
"packages/cli": {
"name": "@qrun-io/qqq-cli",
"version": "0.2.0-dev",
"version": "1.0.0-rc.1",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-core": "0.1.0-dev",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
"@qrun-io/qqq-core": "1.0.0-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.2",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.2",
"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-rc.2",
"license": "Apache-2.0",
"dependencies": {
"ajv": "8.20.0"
@@ -2976,14 +2979,17 @@
"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": "0.1.0-dev",
"version": "1.0.0-rc.1",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.2",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
@@ -2996,13 +3002,16 @@
},
"packages/ui-api-client": {
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0-rc.1",
"version": "1.8.0-rc.2",
"license": "Apache-2.0",
"dependencies": {
"openapi-fetch": "0.17.0"
},
"devDependencies": {
"openapi-typescript": "7.13.0"
},
"engines": {
"node": ">=20"
}
}
}
+2
View File
@@ -22,6 +22,8 @@
"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"
},
+22
View File
@@ -0,0 +1,22 @@
# @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 and process comes from the server's metadata.
```sh
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@rc
qqq login --server https://admin.example.com
qqq table query person -w "lastName STARTS_WITH S" -o -id -l 5
```
Requires Node.js 20 or later, or use a single-file binary from the
[releases](https://github.com/QRun-IO/qqq-cli/releases) (no Node needed). Servers: UI API 1.6.0 up to
2.0 (`qqq --version` prints the range).
Grammar, sign-in, profiles, output formats and exit codes: see the
[repository README](https://github.com/QRun-IO/qqq-cli#readme) and
[TRY-IT.md](https://github.com/QRun-IO/qqq-cli/blob/main/TRY-IT.md).
Apache-2.0
+42 -24
View File
@@ -1,34 +1,27 @@
{
"name": "@qrun-io/qqq-cli",
"version": "0.2.0-dev",
"version": "1.0.0-rc.1",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"keywords": [
"qqq",
"qrun",
"ui-api",
"cli"
],
"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"
},
"files": [
"dist",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/cli"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
"commander": "15.0.0",
"@qrun-io/qqq-core": "0.1.0-dev"
},
"exports": {
".": {
"types": "./dist/main.d.ts",
@@ -37,6 +30,31 @@
"./program": {
"types": "./dist/program.d.ts",
"default": "./dist/program.js"
}
},
"./package.json": "./package.json"
},
"files": [
"dist",
"!dist/test",
"!dist/**/*.map",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test dist/test/*.test.js",
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-core": "1.0.0-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.2",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.2",
"commander": "15.0.0"
}
}
+25 -6
View File
@@ -10,7 +10,25 @@ import { fileURLToPath } from 'node:url'
import type { Command } from 'commander'
import { UsageError, type Context } from '../context.js'
/** The TUI entry in this checkout (dev); a packaged binary replaces this in M5. */
/**
* 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'))
@@ -26,11 +44,9 @@ export function registerUi(program: Command, _ctx: () => Context): void {
.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((route: string[], options: { theme?: string; ascii?: boolean; linear?: boolean; mouse?: boolean; check?: boolean }) => {
const entry = tuiEntry()
if (!existsSync(entry)) throw new UsageError(`The TUI is not in this installation (looked for ${entry}).`)
.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 = ['run', entry]
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)
@@ -40,7 +56,10 @@ export function registerUi(program: Command, _ctx: () => Context): void {
if (options.check === true) args.push('--check')
if (options.check === false) args.push('--no-check')
if (route.length) args.push('--', ...route)
const result = spawnSync(process.env.QQQ_BUN ?? 'bun', args, { stdio: 'inherit' })
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
})
+2 -3
View File
@@ -11,9 +11,8 @@
*/
import { Command, CommanderError } from 'commander'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { Context, EXIT, describeError, io } from './context.js'
import { CLI_VERSION } from './session.js'
import { versionText } from './version.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
@@ -38,7 +37,7 @@ export function createProgram(): Command {
program
.name('qqq')
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`, '--version', 'print the version')
.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)')
+14 -2
View File
@@ -39,13 +39,25 @@ function openInterface(out: NodeJS.WritableStream, input: NodeJS.ReadableStream
return rl
}
/** Ctrl-D (end of input) at a question is a usage error; an abort the caller asked for stays an abort. */
/**
* 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 rl.question(text, { signal })
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)
}
}
+3 -1
View File
@@ -21,7 +21,9 @@ import {
} from '@qrun-io/qqq-ui-api-client'
import { defaultSecretStore, readConfig, resolveProfile, upsertProfile, type ResolvedProfile, type SecretStore } from '@qrun-io/qqq-core'
export const CLI_VERSION = '0.2.0-dev'
import { CLI_VERSION } from './version.js'
export { CLI_VERSION }
export const CLIENT_CHANNEL = `cli/qqq-cli ${CLI_VERSION}`
/** What is stored per profile: OAuth2 tokens from the device flow, or a server session from manageSession. */
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The CLI's version and the UI API versions it supports, printed by `qqq --version` and sent in
* X-QQQ-Client. CLI_VERSION must equal packages/cli/package.json (scripts/lint.mjs checks it).
* The single-file binaries (scripts/build-binaries.mjs) define QQQ_BUILD_SHA, QQQ_BUILD_TARGET and
* QQQ_BUILD_UI (true when the terminal UI is bundled);
* an npm install leaves them undefined.
*/
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
declare const QQQ_BUILD_SHA: string | undefined
declare const QQQ_BUILD_TARGET: string | undefined
declare const QQQ_BUILD_UI: boolean | undefined
export const CLI_VERSION = '1.0.0-rc.1'
/** The oldest UI API a server may serve for this CLI (operationIds, clientCapabilities and traceId since 1.6.0). */
export const UI_API_MIN_VERSION = '1.6.0'
/** The supported range: from UI_API_MIN_VERSION up to, not including, the next major version. */
export const UI_API_RANGE = `>=${UI_API_MIN_VERSION} <2.0.0`
export const BUILD_SHA = typeof QQQ_BUILD_SHA === 'string' ? QQQ_BUILD_SHA : undefined
export const BUILD_TARGET = typeof QQQ_BUILD_TARGET === 'string' ? QQQ_BUILD_TARGET : undefined
/** True in a binary that carries the terminal UI. */
export const BUILD_UI = typeof QQQ_BUILD_UI === 'boolean' ? QQQ_BUILD_UI : false
/** The text of `qqq --version`. */
export function versionText(): string {
const build = BUILD_TARGET ? `${BUILD_TARGET} binary${BUILD_UI ? ' with ui' : ''}${BUILD_SHA ? `, ${BUILD_SHA}` : ''}` : `node ${process.versions.node}`
return [
`qqq ${CLI_VERSION} (${build})`,
`UI API client ${UI_API_VERSION}; servers UI API ${UI_API_RANGE}`,
].join('\n')
}
+11 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-ui-api-conformance",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"description": "Black-box conformance suite for QQQ UI API (/qqq/v1) servers, plus qqq-dev-idp, a local OIDC provider for testing native clients.",
"license": "Apache-2.0",
"type": "module",
@@ -12,11 +12,17 @@
},
"files": [
"dist",
"README.md"
"!dist/test",
"!dist/**/*.map",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"url": "git+https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/conformance"
},
"publishConfig": {
@@ -24,7 +30,8 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
"test": "node --test dist/test/*.test.js",
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"ajv": "8.20.0"
+37 -6
View File
@@ -1,22 +1,53 @@
{
"name": "@qrun-io/qqq-core",
"version": "0.1.0-dev",
"version": "1.0.0-rc.1",
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
"keywords": [
"qqq",
"qrun",
"ui-api"
],
"license": "Apache-2.0",
"homepage": "https://github.com/QRun-IO/qqq-cli/tree/main/packages/core#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/core"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"!dist/test",
"!dist/**/*.map",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"files": ["dist", "README.md"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
"test": "node --test dist/test/*.test.js",
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.2",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
+24
View File
@@ -0,0 +1,24 @@
# @qrun-io/qqq-ui-api-client
A typed TypeScript client for the [QQQ](https://github.com/QRun-IO/qqq) UI API (`/qqq/v1`), generated
from the merged OpenAPI document (core plus the extension documents of the qbits), with OAuth2
device-flow and PKCE sign-in, token refresh and a realtime (SSE) reader.
```ts
import { createUiApiClient, unwrap } from '@qrun-io/qqq-ui-api-client'
const api = createUiApiClient({ baseUrl: 'https://admin.example.com', token: () => process.env.QQQ_TOKEN!, 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 } },
}))
```
The package version follows the UI API version of the document it was generated from (`UI_API_VERSION`);
the document itself is exported as `@qrun-io/qqq-ui-api-client/openapi/qqq-v1.json`. Node.js 20 or later,
or any runtime with `fetch`.
More: the [repository README](https://github.com/QRun-IO/qqq-cli#readme) and QQQ's
[`UI-API-CLIENTS.md`](https://github.com/QRun-IO/qqq/blob/develop/docs/UI-API-CLIENTS.md).
Apache-2.0
+29 -9
View File
@@ -1,27 +1,46 @@
{
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0-rc.1",
"version": "1.8.0-rc.2",
"description": "Typed TypeScript client for the QQQ UI API (/qqq/v1), generated from the merged OpenAPI document (core + extensions), with OAuth2 device-flow sign-in, token refresh and a realtime (SSE) reader.",
"keywords": [
"qqq",
"qrun",
"ui-api",
"cli"
],
"license": "Apache-2.0",
"homepage": "https://github.com/QRun-IO/qqq-cli/tree/main/packages/ui-api-client#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/ui-api-client"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./openapi/qqq-v1.json": "./openapi/qqq-v1.json"
"./openapi/qqq-v1.json": "./openapi/qqq-v1.json",
"./openapi/extension-sources.json": "./openapi/extension-sources.json",
"./package.json": "./package.json"
},
"files": [
"dist",
"!dist/test",
"!dist/**/*.map",
"openapi",
"README.md"
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://github.com/QRun-IO/qqq-cli.git",
"directory": "packages/ui-api-client"
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
@@ -29,7 +48,8 @@
"scripts": {
"generate": "openapi-typescript openapi/qqq-v1.json --output src/schema.ts && node ../../scripts/fix-recursive-types.mjs src/schema.ts",
"build": "tsc -p tsconfig.json",
"test": "node --test \"dist/test/*.test.js\""
"test": "node --test dist/test/*.test.js",
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"openapi-fetch": "0.17.0"
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Entry point of a qqq binary that carries the terminal UI (scripts/build-binaries.mjs --with-tui).
* Only Bun builds it: the CLI stays a Node program, and `qqq ui` hands over to the bundled TUI in the
* same process instead of spawning `bun run packages/tui/src/main.tsx` from a checkout.
*/
globalThis.__qqqRunTui = async (args: string[]) => {
process.argv = [process.argv[0]!, 'qqq-ui', ...args]
await import('../packages/tui/src/main.tsx')
}
await import('../packages/cli/src/main.ts')
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Builds single-file executables of the qqq CLI with `bun build --compile`: one per target, written
* to dist/qqq-<os>-<arch>[.exe] with a SHA256SUMS file beside them (source maps go to dist/sourcemaps/,
* so the release glob dist/qqq-* does not upload them).
*
* The bundle is packages/cli/src/main.ts with the workspace packages (@qrun-io/qqq-ui-api-client and
* @qrun-io/qqq-core, from their built dist) and every npm dependency inlined. The operations index
* generated from the OpenAPI document (packages/core/src/api/operations.ts) is compiled in; this
* script first checks that it lists exactly the operations of packages/ui-api-client/openapi/qqq-v1.json,
* so a binary can never ship a stale index. The CLI version comes from packages/cli/package.json
* (it must equal packages/cli/src/version.ts) and the git commit is stamped into `qqq --version`
* through QQQ_BUILD_SHA and QQQ_BUILD_TARGET.
*
* x64 targets use Bun's baseline (pre-AVX2) runtime so the binaries also run on older CPUs and
* under Rosetta 2 or QEMU emulation.
*
* With --with-tui the binary also carries the terminal UI (packages/tui: OpenTUI's native library and
* the React renderer): the entry is scripts/binary-with-tui.ts, `qqq ui` runs in the same process,
* and `qqq --version` says "with ui". OpenTUI's native package for every target is installed first
* (bun install --os='*' --cpu='*' in packages/tui); Linux targets pin OPENTUI_LIBC to their libc.
*
* Usage: node scripts/build-binaries.mjs [--targets darwin-arm64,linux-x64,...] [--host] [--musl]
* [--with-tui] [--no-build] [--out dist]
* --targets comma-separated subset of the default targets (and the musl ones)
* --host only the target of this machine
* --musl also build linux-x64-musl and linux-arm64-musl (best effort)
* --with-tui bundle the terminal UI too
* --no-build skip `npm run build` (the workspace packages must already be built)
* Needs Bun 1.4 or later on the PATH; cross-compiling downloads each target's Bun runtime once.
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const TARGETS = {
'darwin-arm64': 'bun-darwin-arm64',
'darwin-x64': 'bun-darwin-x64-baseline',
'linux-x64': 'bun-linux-x64-baseline',
'linux-arm64': 'bun-linux-arm64',
'windows-x64': 'bun-windows-x64-baseline',
}
/** OpenTUI's native package per target (only needed with --with-tui). */
const OPENTUI_NATIVE = {
'darwin-arm64': 'core-darwin-arm64', 'darwin-x64': 'core-darwin-x64', 'linux-x64': 'core-linux-x64',
'linux-arm64': 'core-linux-arm64', 'windows-x64': 'core-win32-x64', 'linux-x64-musl': 'core-linux-x64-musl',
'linux-arm64-musl': 'core-linux-arm64-musl',
}
const MUSL_TARGETS = {
'linux-x64-musl': 'bun-linux-x64-musl-baseline',
'linux-arm64-musl': 'bun-linux-arm64-musl',
}
function parseArgs(argv) {
const args = { targets: undefined, host: false, musl: false, tui: false, build: true, out: 'dist' }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--targets') args.targets = argv[++i].split(',').map((t) => t.trim()).filter(Boolean)
else if (a === '--host') args.host = true
else if (a === '--musl') args.musl = true
else if (a === '--with-tui') args.tui = true
else if (a === '--no-build') args.build = false
else if (a === '--out') args.out = argv[++i]
else if (a === '-h' || a === '--help') {
console.log('Usage: node scripts/build-binaries.mjs [--targets a,b] [--host] [--musl] [--with-tui] [--no-build] [--out dist]')
process.exit(0)
} else fail(`unknown option ${a}`)
}
return args
}
function fail(message) {
console.error(`build-binaries: ${message}`)
process.exit(1)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', ...options })
if (result.error) fail(`${command}: ${result.error.message}`)
if (result.status !== 0) fail(`${command} ${args.join(' ')} exited ${result.status}`)
}
function hostTarget() {
const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
const arch = { arm64: 'arm64', x64: 'x64' }[process.arch]
if (!os || !arch) fail(`no target for this host (${process.platform}-${process.arch})`)
return `${os}-${arch}`
}
/** The CLI version from package.json, checked against the constant compiled into the CLI. */
function cliVersion() {
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const source = readFileSync(join(root, 'packages/cli/src/version.ts'), 'utf8')
const constant = source.match(/export const CLI_VERSION = '([^']+)'/)?.[1]
if (constant !== version) fail(`packages/cli/src/version.ts says ${constant}, packages/cli/package.json says ${version}`)
return version
}
/** The commit being built, with -dirty when the tree has changes. */
function gitSha() {
try {
const sha = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
const dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }).trim()
return dirty ? `${sha}-dirty` : sha
} catch {
return 'unknown'
}
}
/** Fails unless the compiled-in operations index lists exactly the document's operations. */
function checkOperationsIndex() {
const spec = JSON.parse(readFileSync(join(root, 'packages/ui-api-client/openapi/qqq-v1.json'), 'utf8'))
const fromSpec = new Set()
for (const item of Object.values(spec.paths ?? {})) {
for (const op of Object.values(item)) if (op && typeof op === 'object' && op.operationId) fromSpec.add(op.operationId)
}
const index = readFileSync(join(root, 'packages/core/src/api/operations.ts'), 'utf8')
const fromIndex = new Set([...index.matchAll(/\{"id":"([^"]+)"/g)].map((m) => m[1]))
const missing = [...fromSpec].filter((id) => !fromIndex.has(id))
const extra = [...fromIndex].filter((id) => !fromSpec.has(id))
if (missing.length || extra.length) {
fail(`operations index is stale (run npm run generate): missing ${missing.join(', ') || 'none'}; extra ${extra.join(', ') || 'none'}`)
}
return { operations: fromSpec.size, apiVersion: spec.info?.version }
}
function megabytes(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const args = parseArgs(process.argv.slice(2))
const all = { ...TARGETS, ...MUSL_TARGETS }
let names = args.host ? [hostTarget()] : args.targets ?? Object.keys(TARGETS)
if (args.musl && !args.targets) names = [...names, ...Object.keys(MUSL_TARGETS)]
for (const name of names) if (!all[name]) fail(`unknown target ${name} (known: ${Object.keys(all).join(', ')})`)
const bun = spawnSync('bun', ['--version'], { encoding: 'utf8' })
if (bun.status !== 0) fail('bun is not on the PATH (https://bun.sh); it is only needed to build binaries')
const version = cliVersion()
const sha = gitSha()
const spec = checkOperationsIndex()
console.log(`qqq ${version} (${sha}), bun ${bun.stdout.trim()}, UI API document ${spec.apiVersion} with ${spec.operations} operations`)
if (args.build) run('npm', ['run', 'build', '--silent'], { shell: process.platform === 'win32' })
for (const entry of ['packages/ui-api-client/dist/index.js', 'packages/core/dist/index.js']) {
if (!existsSync(join(root, entry))) fail(`${entry} is missing; run npm run build`)
}
if (args.tui) {
// the TUI's own dependencies, with OpenTUI's native package for every platform
run('bun', ['install', '--frozen-lockfile', '--os=*', '--cpu=*'], { cwd: join(root, 'packages/tui'), stdio: ['ignore', 'ignore', 'inherit'] })
for (const name of names) {
const native = join(root, 'packages/tui/node_modules/@opentui', OPENTUI_NATIVE[name])
if (!existsSync(native)) fail(`OpenTUI's native package @opentui/${OPENTUI_NATIVE[name]} is missing for ${name}`)
}
}
const outDir = resolve(root, args.out)
mkdirSync(outDir, { recursive: true })
const built = []
for (const name of names) {
const file = `qqq-${name}${name.startsWith('windows') ? '.exe' : ''}`
const outfile = join(outDir, file)
const started = Date.now()
const tuiDefines = !args.tui ? [] : [
'--define=QQQ_BUILD_UI=true',
...(name.startsWith('linux') ? [`--define=process.env.OPENTUI_LIBC=${JSON.stringify(name.endsWith('-musl') ? 'musl' : 'glibc')}`] : []),
]
run('bun', [
'build', args.tui ? 'scripts/binary-with-tui.ts' : 'packages/cli/src/main.ts',
'--compile', `--target=${all[name]}`, `--outfile=${outfile}`,
'--minify-syntax', '--minify-whitespace', '--sourcemap',
`--define=QQQ_BUILD_SHA=${JSON.stringify(sha)}`,
`--define=QQQ_BUILD_TARGET=${JSON.stringify(name)}`,
...tuiDefines,
], { stdio: ['ignore', 'ignore', 'inherit'] })
// bun writes the source map beside the binary; keep it out of the release files (dist/qqq-*)
if (existsSync(`${outfile}.map`)) {
mkdirSync(join(outDir, 'sourcemaps'), { recursive: true })
renameSync(`${outfile}.map`, join(outDir, 'sourcemaps', `${file}.map`))
}
const bytes = readFileSync(outfile)
built.push({ file, size: statSync(outfile).size, sha256: createHash('sha256').update(bytes).digest('hex'), seconds: (Date.now() - started) / 1000 })
}
// keep the sums of binaries built by an earlier run that this run did not rebuild
const sums = new Map()
const sumsFile = join(outDir, 'SHA256SUMS')
if (existsSync(sumsFile)) {
for (const line of readFileSync(sumsFile, 'utf8').split('\n')) {
const [hash, file] = line.split(/\s+/)
if (hash && file && existsSync(join(outDir, file))) sums.set(file, hash)
}
}
for (const b of built) sums.set(b.file, b.sha256)
writeFileSync(sumsFile, [...sums].sort(([a], [b]) => a.localeCompare(b)).map(([file, hash]) => `${hash} ${file}`).join('\n') + '\n')
console.log('')
for (const b of built) console.log(`${b.file.padEnd(26)} ${megabytes(b.size).padStart(9)} ${b.seconds.toFixed(1)}s sha256 ${b.sha256.slice(0, 16)}`)
console.log(`${args.out}/SHA256SUMS`)
const host = `qqq-${hostTarget()}${process.platform === 'win32' ? '.exe' : ''}`
if (built.some((b) => b.file === host)) {
const smoke = spawnSync(join(outDir, host), ['--version'], { encoding: 'utf8' })
if (smoke.status !== 0 || !smoke.stdout.includes(`qqq ${version}`)) fail(`${host} --version failed: ${smoke.stderr || smoke.stdout}`)
console.log(`\n${host} --version:\n${smoke.stdout.trim()}`)
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* prepack hook of each published package: copies the repository's LICENSE into the package
* directory so npm packs it (npm only picks up a LICENSE from the package root). The copies are
* git-ignored.
*
* Usage (from a package directory): node ../../scripts/copy-license.mjs
*/
import { copyFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
copyFileSync(resolve(import.meta.dirname, '..', 'LICENSE'), join(process.cwd(), 'LICENSE'))
+110
View File
@@ -0,0 +1,110 @@
#!/bin/sh
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Installs the qqq CLI single-file binary for this machine from the GitHub releases of
# QRun-IO/qqq-cli, after checking it against the release's SHA256SUMS.
#
# curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
# curl -fsSL .../install.sh | sh -s -- --version 1.0.0-rc.1 --dir /usr/local/bin
#
# Options (or environment):
# --version V (QQQ_VERSION) a release such as 1.0.0-rc.1; default: the newest cli-v* release,
# prereleases included
# --dir D (QQQ_INSTALL_DIR) where qqq goes; default: ~/.local/bin
# --musl take the musl build on Linux (detected on Alpine and other musl hosts)
# QQQ_DOWNLOAD_BASE release download root (default https://github.com/QRun-IO/qqq-cli/releases/download),
# for mirrors and testing
# Supported: macOS arm64 and x64, Linux x64 and arm64 (glibc and musl). On Windows use Scoop or the .exe.
set -eu
repo="QRun-IO/qqq-cli"
version="${QQQ_VERSION:-}"
dir="${QQQ_INSTALL_DIR:-$HOME/.local/bin}"
base="${QQQ_DOWNLOAD_BASE:-https://github.com/$repo/releases/download}"
musl=""
say() { printf '%s\n' "$*" >&2; }
die() { say "qqq install: $*"; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || die "--version needs a value"; version="$2"; shift 2 ;;
--dir) [ $# -ge 2 ] || die "--dir needs a value"; dir="$2"; shift 2 ;;
--musl) musl="-musl"; shift ;;
-h|--help) sed -n '2,20p' "$0" 2>/dev/null || true; exit 0 ;;
*) die "unknown option $1" ;;
esac
done
if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL --retry 3 -o "$2" "$1"; }
fetch_text() { curl -fsSL --retry 3 "$1"; }
elif command -v wget >/dev/null 2>&1; then
fetch() { wget -q -O "$2" "$1"; }
fetch_text() { wget -q -O - "$1"; }
else
die "needs curl or wget"
fi
case "$(uname -s)" in
Darwin) os=darwin ;;
Linux) os=linux ;;
MINGW*|MSYS*|CYGWIN*) die "on Windows install with Scoop (scoop install qqq) or download qqq-windows-x64.exe from https://github.com/$repo/releases" ;;
*) die "unsupported operating system $(uname -s)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
arm64|aarch64) arch=arm64 ;;
*) die "unsupported architecture $(uname -m)" ;;
esac
if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then
arch=arm64 # a shell running under Rosetta on Apple silicon: install the native build
fi
if [ "$os" = linux ] && [ -z "$musl" ]; then
if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then musl="-musl"; fi
fi
[ "$os" = linux ] || musl=""
if [ -z "$version" ]; then
version="$(fetch_text "https://api.github.com/repos/$repo/releases?per_page=30" \
| grep -o '"tag_name": *"cli-v[^"]*"' | head -n 1 | sed 's/.*"cli-v\([^"]*\)"/\1/')" \
|| true
[ -n "$version" ] || die "could not find a cli-v* release of $repo; pass --version"
fi
version="${version#v}"
version="${version#cli-v}"
file="qqq-$os-$arch$musl"
url="$base/cli-v$version/$file"
tmp="$(mktemp -d 2>/dev/null || mktemp -d -t qqq)"
trap 'rm -rf "$tmp"' EXIT
trap 'exit 130' INT TERM
say "Downloading qqq $version ($file)"
fetch "$url" "$tmp/$file" || die "download failed: $url"
fetch "$base/cli-v$version/SHA256SUMS" "$tmp/SHA256SUMS" || die "download failed: $base/cli-v$version/SHA256SUMS"
expected="$(awk -v f="$file" '$2 == f { print $1 }' "$tmp/SHA256SUMS")"
[ -n "$expected" ] || die "SHA256SUMS has no entry for $file"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$tmp/$file" | awk '{ print $1 }')"
elif command -v shasum >/dev/null 2>&1; then
actual="$(shasum -a 256 "$tmp/$file" | awk '{ print $1 }')"
else
die "needs sha256sum or shasum to check the download"
fi
[ "$actual" = "$expected" ] || die "checksum mismatch for $file (expected $expected, got $actual)"
mkdir -p "$dir"
chmod 755 "$tmp/$file"
mv "$tmp/$file" "$dir/qqq"
say "Installed $dir/qqq"
"$dir/qqq" --version >&2 || die "$dir/qqq does not run on this machine${musl:+ (musl builds need libstdc++ and libgcc: apk add libstdc++ libgcc)}"
case ":$PATH:" in
*":$dir:"*) ;;
*) say "Add $dir to your PATH, for example: echo 'export PATH=\"$dir:\$PATH\"' >> ~/.profile" ;;
esac
say "Next: qqq login --server https://your-qqq-server"
+26 -2
View File
@@ -4,8 +4,11 @@
*/
/**
* Repository lint: every TypeScript and script source carries the license header, and no file under
* packages/* /src imports from another package's src (only from its built entry point).
* Repository lint: every TypeScript and script source carries the license header, no file under
* packages/* /src imports from another package's src (only from its built entry point), and the
* release versions agree: the CLI's compiled-in version with its package.json, the client's
* UI_API_VERSION with its OpenAPI document, and every workspace dependency with the version of the
* workspace it names.
*
* Usage: node scripts/lint.mjs
*/
@@ -33,7 +36,28 @@ function check(path) {
if (cross) problems.push(`${relative('.', path)}: imports another package's src (${cross[0]}); import the package by name`)
}
function checkVersions() {
const read = (path) => JSON.parse(readFileSync(path, 'utf8'))
const workspaces = Object.fromEntries(read('package.json').workspaces.map((dir) => {
const pkg = read(join(dir, 'package.json'))
return [pkg.name, { dir, pkg }]
}))
const cli = workspaces['@qrun-io/qqq-cli'].pkg
const cliConstant = readFileSync('packages/cli/src/version.ts', 'utf8').match(/export const CLI_VERSION = '([^']+)'/)?.[1]
if (cliConstant !== cli.version) problems.push(`packages/cli/src/version.ts: CLI_VERSION ${cliConstant} != packages/cli/package.json ${cli.version}`)
const uiApiConstant = readFileSync('packages/ui-api-client/src/index.ts', 'utf8').match(/export const UI_API_VERSION = '([^']+)'/)?.[1]
const documentVersion = read('packages/ui-api-client/openapi/qqq-v1.json').info?.version
if (uiApiConstant !== documentVersion) problems.push(`packages/ui-api-client/src/index.ts: UI_API_VERSION ${uiApiConstant} != document info.version ${documentVersion}`)
for (const { dir, pkg } of Object.values(workspaces)) {
for (const [name, range] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
const target = workspaces[name]
if (target && range !== target.pkg.version) problems.push(`${dir}/package.json: depends on ${name} ${range}, the workspace is ${target.pkg.version}`)
}
}
}
for (const root of roots) walk(root)
checkVersions()
if (problems.length) {
console.error(problems.join('\n'))
process.exit(1)
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Release check for the CLI packages. Runs everything a release needs locally and then prints the
* commands that tag and publish it. It never tags, pushes or publishes.
*
* 1. the working tree is clean (no staged, unstaged or untracked changes) and the tags are free
* 2. npm ci, then the steps the qqq-orb `cli` profile runs: npm run generate with no drift in
* packages/ui-api-client/src/schema.ts or packages/core/src/api/operations.ts, build, test;
* plus npm run lint (versions, headers) and the TUI's Bun tests and type check (npm run test:tui,
* typecheck:tui; the TUI is not published, and is bundled only with --with-tui, but a release
* must not break it)
* 3. npm pack --dry-run for each published package
* 4. the single-file binaries (scripts/build-binaries.mjs; CLI only, or with the terminal UI
* with --with-tui) and dist/SHA256SUMS
* 5. prints the tag, GitHub release and npm publish commands
*
* Usage: node scripts/release.mjs [--allow-dirty] [--skip-binaries] [--skip-install] [--with-tui]
* --with-tui build the binaries with the terminal UI bundled (scripts/build-binaries.mjs --with-tui)
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const argv = new Set(process.argv.slice(2))
for (const a of argv) if (!['--allow-dirty', '--skip-binaries', '--skip-install', '--with-tui'].includes(a)) fail(`unknown option ${a}`)
const shell = process.platform === 'win32'
/** Published packages, in publish order (each depends only on the ones before it). */
const PUBLISHED = ['packages/ui-api-client', 'packages/core', 'packages/conformance', 'packages/cli']
function fail(message) {
console.error(`\nrelease: ${message}`)
process.exit(1)
}
function git(...args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim()
}
function step(title, command, args) {
console.log(`\n== ${title}: ${command} ${args.join(' ')}`)
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', shell })
if (result.status !== 0) fail(`${title} failed (exit ${result.status})`)
}
const pkg = (dir) => JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8'))
const versions = Object.fromEntries(PUBLISHED.map((dir) => [dir, pkg(dir)]))
const client = versions['packages/ui-api-client']
const cli = versions['packages/cli']
const orbTag = `v${client.version}`
const cliTag = `cli-v${cli.version}`
console.log('Release check')
for (const { name, version } of Object.values(versions)) console.log(` ${name.padEnd(34)} ${version}`)
const status = git('status', '--porcelain')
if (status && !argv.has('--allow-dirty')) fail(`the working tree is not clean:\n${status}\n(commit or stash first; --allow-dirty for a trial run)`)
for (const tag of [orbTag, cliTag]) {
if (git('tag', '--list', tag)) fail(`tag ${tag} already exists; bump the versions first`)
}
const sha = git('rev-parse', '--short=12', 'HEAD')
const branch = git('rev-parse', '--abbrev-ref', 'HEAD')
if (!argv.has('--skip-install')) step('install', 'npm', ['ci'])
step('generate', 'npm', ['run', 'generate'])
const drift = git('status', '--porcelain', '--', 'packages/ui-api-client/src/schema.ts', 'packages/core/src/api/operations.ts')
if (drift) fail(`generated sources drifted from the OpenAPI document (commit them):\n${drift}`)
step('build', 'npm', ['run', 'build'])
step('test', 'npm', ['test'])
step('lint', 'npm', ['run', 'lint'])
step('TUI tests', 'npm', ['run', 'test:tui'])
step('TUI type check', 'npm', ['run', 'typecheck:tui'])
for (const dir of PUBLISHED) step(`pack ${versions[dir].name}`, 'npm', ['pack', '--dry-run', '--silent', '-w', versions[dir].name])
if (!argv.has('--skip-binaries')) step('binaries', 'node', ['scripts/build-binaries.mjs', '--no-build', '--musl', ...(argv.has('--with-tui') ? ['--with-tui'] : [])])
const registry = 'https://npm.pkg.github.com'
console.log(`
All checks passed at ${branch}@${sha}. Nothing was tagged or published. To release:
# 1. tag (signed) and push. ${orbTag} makes the qqq-orb cli profile publish the client, conformance
# and CLI packages to GitHub Packages with dist-tag rc (the orb checks ${orbTag} = client version).
git tag -s ${orbTag} -m "UI API client ${client.version}, qqq-cli ${cli.version}"
git tag -s ${cliTag} -m "qqq-cli ${cli.version}"
git push origin ${orbTag} ${cliTag}
# 2. the orb does not publish @qrun-io/qqq-core yet, and the CLI depends on it: publish it by hand
# before (or right after) the orb job, or the CLI package cannot be installed.
npm publish -w ${versions['packages/core'].name} --registry ${registry} --tag rc
# without the orb (all four, in dependency order):
${PUBLISHED.map((dir) => ` npm publish -w ${versions[dir].name} --registry ${registry} --tag rc`).join('\n')}
# 3. the binaries, as a prerelease with their checksums
gh release create ${cliTag} --repo QRun-IO/qqq-cli --prerelease --title "qqq-cli ${cli.version}" \\
--notes-file <(sed -n '/^## \\[${cli.version.replace(/\./g, '\\.')}\\]/,/^## \\[/p' CHANGELOG.md | sed '$d') \\
dist/qqq-* dist/SHA256SUMS
# 4. fill the sha256 values from dist/SHA256SUMS into docs/packaging/qqq.rb and qqq.json and
# open pull requests on the Homebrew tap and the Scoop bucket (see docs/packaging/README.md).
`)
const conformance = versions['packages/conformance']
console.log(`Note: the orb also publishes ${conformance.name} ${conformance.version} on ${orbTag}; bump it first if that version is already in the registry.`)
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Fills the version and sha256 values of the packaging drafts (docs/packaging/qqq.rb for Homebrew,
* docs/packaging/qqq.json for Scoop) from packages/cli/package.json and a release's SHA256SUMS.
* Run it on the binaries that were uploaded to the GitHub release, not on a local rebuild: the
* commit is stamped into each binary, so every build has different checksums.
*
* Usage: node scripts/update-packaging.mjs [dist/SHA256SUMS]
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS'))
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const sums = new Map(readFileSync(sumsPath, 'utf8').trim().split('\n').map((line) => line.split(/\s+/).reverse()))
function sum(file) {
const hash = sums.get(file)
if (!hash) {
console.error(`update-packaging: ${sumsPath} has no ${file}`)
process.exit(1)
}
return hash
}
const formulaPath = join(root, 'docs/packaging/qqq.rb')
let formula = readFileSync(formulaPath, 'utf8').replace(/^(\s*version ")[^"]+"/m, `$1${version}"`)
formula = formula.replace(/(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")[0-9a-f]{64}"/g, (_, head, file) => `${head}${sum(file)}"`)
writeFileSync(formulaPath, formula)
const manifestPath = join(root, 'docs/packaging/qqq.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.version = version
manifest.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
manifest.architecture['64bit'].hash = sum('qqq-windows-x64.exe')
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n')
console.log(`docs/packaging/qqq.rb and qqq.json now name ${version} with the sums from ${sumsPath}`)