Merge branch 'feat/m1-kernel-cli' into feat/m3-tui
This commit is contained in:
27 files changed
+1341
-144
No files matched your search
@@ -3,3 +3,4 @@ dist/
|
||||
*.tgz
|
||||
.env
|
||||
.spec-src/
|
||||
packages/*/LICENSE
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Generated
+17
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
@@ -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()}`)
|
||||
}
|
||||
@@ -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'))
|
||||
Executable
+110
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.`)
|
||||
@@ -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}`)
|
||||
Reference in new issue
Block a user