Compare commits

..
Author SHA1 Message Date
james.maes 512d597934 Merge remote-tracking branch 'origin/main' into feat/tui-shift-fkeys 2026-11-01 13:37:28 -06:00
james.maesandClaude Fable 5.1 60a48e7d5d feat(tui): Shift+F1..F10 bank, two-row F-key bar, one F-key decoder
The F1-F10 bar is full, so Shift+F1..F10 become a second bank (Keys,
Servers, SQL, Bulk, Metadata, Views, Copy, Export, History, Assistant),
kept as one table bound to named actions so other keys (F12, Ctrl+G) can
bind the same action. Keys that do not apply are dimmed and say why.

The bar shows the Shift row under the primary row from 30 lines; :fkeys
one|two|auto (F9 > Settings) picks, and one-row mode swaps the Shift row
in for 2 s after a Shift+F press, since Shift alone cannot be detected.

keys/fkeys.ts decodes xterm, kitty, legacy (vt220, rxvt, Terminal.app)
and rxvt suffix forms. OpenTUI drops Shift+F3 (CSI 1;2R looks like a
cursor report) and reads no legacy forms, so those are rewritten on the
input stream into CSI n;m ~, outside the startup cursor-query window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:33:23 -06:00
75 changed files with 483 additions and 2253 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /.*/
-17
View File
@@ -1,17 +0,0 @@
name: build
on:
push:
branches: ['**']
jobs:
node:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
with:
node-version: '22'
# The TUI (packages/tui) is not an npm workspace and needs Bun; build and test cover the four
# Node workspaces only, as the old CI did.
commands: |
npm run generate
git diff --exit-code -- packages/ui-api-client/src/schema.ts packages/core/src/api/operations.ts
npm run build
npm test
npm run lint
-304
View File
@@ -1,304 +0,0 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Release on a v<CLI version> tag (docs/release-runbook.md):
# linux scripts/release.mjs gate; linux-x64, linux-arm64, darwin-x64 binaries (cross); draft Gitea release
# (no musl builds: bun installs no OpenTUI musl native package on a glibc host)
# macos native darwin-arm64 binary, built and smoke-tested on the macOS runner
# windows native windows-x64 binary, built and smoke-tested on the Windows runner
# publish SHA256SUMS; publish the release and check its downloads; the four npm packages to the
# QRun-IO Gitea npm registry; for a final version pull requests to the Homebrew tap and the Scoop bucket
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
env:
NODE_VERSION: 22.23.4
BUN_VERSION: 1.4.4
BUN_LINUX_SHA256: e0446eb55995772eb9894bebae9a6be368d985a8b9271ee124486f9946eedd76 # bun-linux-x64-baseline.zip
BUN_DARWIN_SHA256: c240d7f0ccb90c263a849fcdb09b7ee62630d921348ed6aafda86b9d577bc510 # bun-darwin-aarch64.zip
BUN_WINDOWS_SHA256: 0f20ce6674d4f2dbcc8c5d59fdf99f7ba45f8c3b367e0f5781cbfa30a1c78d99 # bun-windows-x64-baseline.zip
jobs:
linux:
runs-on: container-build
timeout-minutes: 60
container:
image: node:22.23.4-bookworm
volumes:
- /var/lib/gitea-job-workspace:${{ github.workspace }}
env:
HOME: /tmp
CI: 'true'
outputs:
release-id: ${{ steps.draft.outputs.id }}
version: ${{ steps.plan.outputs.version }}
sums: ${{ steps.draft.outputs.sums }}
steps:
- uses: builtin:checkout
with:
fetch-depth: 0
- name: Check the tag
id: plan
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
version=$(node -p 'require("./packages/cli/package.json").version')
[[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "$TAG is not a release tag"; exit 1; }
[[ $TAG == "v$version" ]] || { echo "Tag $TAG does not match packages/cli/package.json $version"; exit 1; }
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Bun
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/bun && cd /tmp/bun
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-linux-x64-baseline.zip"
echo "$BUN_LINUX_SHA256 bun.zip" | sha256sum -c -
python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(".")' bun.zip
chmod +x bun-linux-x64-baseline/bun && echo /tmp/bun/bun-linux-x64-baseline >> "$GITHUB_PATH"
- name: Release gate (scripts/release.mjs)
# npm ci, generate drift, build, readiness (final versions), test, lint, TUI tests and type check, pack dry runs
run: node scripts/release.mjs --skip-binaries
- name: Linux and darwin-x64 binaries
shell: bash
run: |
set -euo pipefail
node scripts/build-binaries.mjs --no-build --targets linux-x64,linux-arm64,darwin-x64
scripts/verify-binary.sh dist/qqq-linux-x64
- name: Draft Gitea release
id: draft
shell: bash
env:
TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.plan.outputs.version }}
run: |
set -euo pipefail
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth="Authorization: token $TOKEN"
if curl -fsS -o /dev/null -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null; then
echo "A release for $TAG already exists; a version is released once"; exit 1
fi
if [[ -f docs/release-notes/$VERSION.md ]]; then notes=$(cat "docs/release-notes/$VERSION.md"); else
notes=$(awk -v v="[$VERSION]" '/^## / { if (found) exit; if (index($0, "## " v) == 1) { found = 1; next } } found { print }' CHANGELOG.md)
fi
[[ -n ${notes//[[:space:]]/} ]] || notes="qqq $VERSION"
body=$(NOTES="$notes" node -e 'process.stdout.write(JSON.stringify({ tag_name: process.env.TAG,
name: `qqq ${process.env.VERSION}`, body: process.env.NOTES, draft: true, prerelease: process.env.VERSION.includes("-") }))')
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" |
node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).id))')
echo "id=$id" >> "$GITHUB_OUTPUT"
echo "sums=$(cd dist && sha256sum qqq-* | tr '\n' ';')" >> "$GITHUB_OUTPUT"
for f in dist/qqq-*; do
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$(basename "$f")"
echo "Attached $(basename "$f") to draft release $id"
done
macos:
needs: linux
runs-on: qqq-macos-arm64
timeout-minutes: 45
env:
CI: 'true'
outputs:
sums: ${{ steps.attach.outputs.sums }}
steps:
- uses: builtin:checkout
- name: Install Node.js and Bun
shell: bash
run: |
set -euo pipefail
tools="$(mktemp -d)" && cd "$tools"
node_file="node-v$NODE_VERSION-darwin-arm64.tar.gz"
curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/$node_file" && curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
grep " $node_file\$" SHASUMS256.txt | shasum -a 256 -c -
tar -xzf "$node_file"
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-darwin-aarch64.zip"
echo "$BUN_DARWIN_SHA256 bun.zip" | shasum -a 256 -c -
unzip -q bun.zip
echo "$tools/node-v$NODE_VERSION-darwin-arm64/bin" >> "$GITHUB_PATH"
echo "$tools/bun-darwin-aarch64" >> "$GITHUB_PATH"
echo "TOOLS_DIR=$tools" >> "$GITHUB_ENV"
- name: Build, test, darwin-arm64 binary
shell: bash
run: |
set -euo pipefail
node --version && bun --version
npm ci
npm run build
npm test
npm run lint
node scripts/build-binaries.mjs --no-build --host
scripts/verify-binary.sh dist/qqq-darwin-arm64
- name: Attach to the draft release
id: attach
shell: bash
env:
TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
run: |
set -euo pipefail
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@dist/qqq-darwin-arm64" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=qqq-darwin-arm64"
echo "Attached qqq-darwin-arm64"
echo "sums=$(cd dist && shasum -a 256 qqq-darwin-arm64 | tr '\n' ';')" >> "$GITHUB_OUTPUT"
- name: Remove the tools
if: always()
shell: bash
run: if [[ -n ${TOOLS_DIR:-} ]]; then rm -rf "$TOOLS_DIR"; fi
windows:
needs: linux
runs-on: qqq-windows-vm6218
timeout-minutes: 45
env:
CI: 'true'
outputs:
sums: ${{ steps.attach.outputs.sums }}
steps:
- uses: builtin:checkout
- name: Install Node.js and Bun
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'
$tools = Join-Path ([IO.Path]::GetTempPath()) ("qqq-tools-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $tools | Out-Null
$nodeFile = "node-v$env:NODE_VERSION-win-x64.zip"
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/$nodeFile" -OutFile "$tools\$nodeFile"
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/SHASUMS256.txt" -OutFile "$tools\SHASUMS256.txt"
$want = ((Get-Content "$tools\SHASUMS256.txt") | Where-Object { $_ -match " $([regex]::Escape($nodeFile))$" }).Split(' ')[0]
if ((Get-FileHash "$tools\$nodeFile" -Algorithm SHA256).Hash -ne $want) { throw "$nodeFile checksum mismatch" }
Expand-Archive "$tools\$nodeFile" -DestinationPath $tools
Invoke-WebRequest "https://github.com/oven-sh/bun/releases/download/bun-v$env:BUN_VERSION/bun-windows-x64-baseline.zip" -OutFile "$tools\bun.zip"
if ((Get-FileHash "$tools\bun.zip" -Algorithm SHA256).Hash -ne $env:BUN_WINDOWS_SHA256) { throw "bun.zip checksum mismatch" }
Expand-Archive "$tools\bun.zip" -DestinationPath $tools
Add-Content $env:GITHUB_PATH "$tools\node-v$env:NODE_VERSION-win-x64"
Add-Content $env:GITHUB_PATH "$tools\bun-windows-x64-baseline"
Add-Content $env:GITHUB_ENV "TOOLS_DIR=$tools"
- name: Build, test, windows-x64 binary
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
node --version; bun --version
npm ci
npm run build
npm test
npm run lint
node scripts/build-binaries.mjs --no-build --host
pwsh -NoProfile -File scripts/verify-binary.ps1 -Binary dist\qqq-windows-x64.exe
- name: Attach to the draft release
id: attach
shell: pwsh
env:
TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
run: |
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
curl.exe -fsS -o NUL -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@dist/qqq-windows-x64.exe" `
"$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$env:RELEASE_ID/assets?name=qqq-windows-x64.exe"
Write-Output "Attached qqq-windows-x64.exe"
$hash = (Get-FileHash dist\qqq-windows-x64.exe -Algorithm SHA256).Hash.ToLowerInvariant()
Add-Content $env:GITHUB_OUTPUT "sums=$hash qqq-windows-x64.exe;"
- name: Remove the tools
if: always()
shell: pwsh
run: if ($env:TOOLS_DIR) { Remove-Item -Recurse -Force $env:TOOLS_DIR -ErrorAction SilentlyContinue }
publish:
needs: [linux, macos, windows]
runs-on: container-build
timeout-minutes: 30
container:
image: node:22.23.4-bookworm
volumes:
- /var/lib/gitea-job-workspace:${{ github.workspace }}
env:
HOME: /tmp
CI: 'true'
TOKEN: ${{ github.token }}
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
VERSION: ${{ needs.linux.outputs.version }}
steps:
- uses: builtin:checkout
- name: SHA256SUMS
shell: bash
env:
SUMS: ${{ needs.linux.outputs.sums }}${{ needs.macos.outputs.sums }}${{ needs.windows.outputs.sums }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
mkdir -p /tmp/assets
tr ';' '\n' <<< "$SUMS" | sed '/^$/d' | sort -k2 > /tmp/assets/SHA256SUMS
cat /tmp/assets/SHA256SUMS
for want in qqq-linux-x64 qqq-linux-arm64 qqq-darwin-x64 qqq-darwin-arm64 qqq-windows-x64.exe; do
grep -Eq "^[0-9a-f]{64} $want\$" /tmp/assets/SHA256SUMS || { echo "No checksum for $want"; exit 1; }
done
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@/tmp/assets/SHA256SUMS" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=SHA256SUMS"
- name: Publish the release and check the downloads
shell: bash
run: |
set -euo pipefail
prerelease=false; [[ $VERSION == *-* ]] && prerelease=true
curl -fsS -o /dev/null -X PATCH -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"draft\":false,\"prerelease\":$prerelease}" "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"
# What users download (the formula, the manifest, install.sh) must match SHA256SUMS.
cd /tmp/assets
while read -r _ file; do
curl -fsSL -o "$file" "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/v$VERSION/$file"
done < SHA256SUMS
sha256sum --check --strict SHA256SUMS
echo "Released: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v$VERSION"
- name: Publish the npm packages
shell: bash
run: |
set -euo pipefail
if [[ -z ${PACKAGES_TOKEN:-} ]]; then echo "PACKAGES_TOKEN is empty"; exit 1; fi
npm ci
npm run build
registry="$GITHUB_SERVER_URL/api/packages/$GITHUB_REPOSITORY_OWNER/npm/"
export NPM_CONFIG_USERCONFIG=/tmp/publish.npmrc
printf '//%s:_authToken=${PACKAGES_TOKEN}\n@qrun-io:registry=%s\n' "${registry#*://}" "$registry" > "$NPM_CONFIG_USERCONFIG"
# In dependency order. A version already in the registry is left alone (versions are immutable),
# so a release republishes nothing that did not change; each package's own version picks its dist-tag.
for dir in packages/ui-api-client packages/core packages/conformance packages/cli; do
name=$(node -p "require('./$dir/package.json').name")
version=$(node -p "require('./$dir/package.json').version")
if npm view "$name@$version" version --registry "$registry" > /dev/null 2>&1; then
echo "$name@$version is already published; skipped"; continue
fi
tag=latest; [[ $version == *-* ]] && tag=next
(cd "$dir" && npm publish --registry "$registry" --tag "$tag")
echo "Published $name@$version ($tag): $(npm view "$name@$version" version --registry "$registry")"
done
- name: Homebrew tap and Scoop bucket (pull requests)
# Final versions: branch release/qqq-<version> and a pull request "qqq <version>" in QRun-IO/homebrew-tap
# and QRun-IO/scoop-bucket (PACKAGES_TOKEN); a human merges. Prereleases, or a refused push, attach
# qqq.rb / qqq.json to the release instead. Never fails the release.
shell: bash
run: bash scripts/publish-packaging.sh
+7
View File
@@ -0,0 +1,7 @@
profile: cli
orb_version: '0.8'
default_branch: main
publish:
release: true
publish_context: qqq-maven-registry-credentials
platforms: [macos-arm64, windows-x64]
-47
View File
@@ -5,15 +5,8 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
## [Unreleased]
### Changed
- TypeScript 5.8.3 to 6.0.3 and `@types/node` 22.15.3 to 26.6.6 across the workspace, `packages/tui` and `packages/tui-spike`. TypeScript 6 no longer loads every `@types/*` package implicitly, so `tsconfig.base.json` now lists `"types": ["node"]`; `@types/node` 26 no longer accepts an `undefined` assertion message, so one conformance test passes a real fallback message. TypeScript 7 is not adopted yet: `openapi-typescript` 7.13.0 (the client generator, peer `typescript ^5.x`) calls the TypeScript JS API (`ts.factory`), which the native 7 compiler does not provide. No behaviour change.
### Fixed
- **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0.
- **Windows: `qqq login --browser` opens the whole authorization URL.** The opener was `cmd /c start`, which splits the URL at every `&`, so the browser received only the part before the first query parameter (`client_id`, `state` and `redirect_uri` were lost) and the loopback redirect never came. The URL now goes to PowerShell in the environment variable `QQQ_BROWSER_URL` (`Start-Process -FilePath $env:QQQ_BROWSER_URL`, a fixed encoded command, hidden window; `QQQ_POWERSHELL` picks the shell). Carried locally by GG Forge since cli-v1.0.0.
- **`qqq ui`: Tags (t) asks the server first.** The record screen reads the record's collaboration features (`getRecordFeatures`) before opening the tag editor and says "Tags are not enabled for this table." or "Your role cannot tag this record." instead of opening an editor whose save the server would refuse. Carried locally by GG Forge since cli-v1.0.0.
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
### Added
@@ -55,23 +48,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
- **`qqq ui` starts on a splash**: Query (the qrun.io mascot) blinking and wagging while it boots, the `qqq`
wordmark, the version and a status line that follows the boot (reading profiles, connecting, signing in,
loading metadata with the table count). Large Query from about 34 rows, the small one beside the
wordmark at 80×24, plain characters under `--ascii`; any key stops the animation, not the boot; shown at
least 600 ms. `--no-splash`, `QQQ_UI_NO_SPLASH=1` or F9 › Settings › Splash on start (`:splash on|off`,
saved as `ui.splash`) skip it.
- **A profile picker on start**: with several profiles and none named (`--profile`, `QQQ_PROFILE`,
`--server`, `QQQ_SERVER`), the Servers picker opens over the splash with the last used profile selected;
Enter connects, `n` adds, the server status fills in as the probes answer, Esc quits with a one-line hint
about `--profile`. With no profile at all the add form opens directly; with exactly one, it connects.
- **Connection failures stay on the splash** with the reason: Enter retries, `c` chooses another profile, `n`
adds one, Esc quits (instead of the bare error on the sign-in screen).
### Changed
- With several profiles and none named, `qqq ui` asks which to connect to instead of using the default
profile (`--profile` or `QQQ_PROFILE` keeps the old behaviour).
### Fixed
@@ -86,29 +62,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
cursor-query window.
## [1.0.1-rc.1] - 2026-11-08
Release candidate built and published by Gitea CI from the tag `v1.0.1-rc.1`, the first through the new
release path: `@qrun-io/qqq-cli` 1.0.1-rc.1, `@qrun-io/qqq-core` 1.0.1-rc.1 and
`@qrun-io/qqq-ui-api-client` 1.8.1-rc.1 (dist-tag `next`) with `@qrun-io/qqq-ui-api-conformance` 1.6.0 on
the QRun-IO Gitea npm registry, and the binaries with `SHA256SUMS` on the Gitea release. It carries the
changes listed under Unreleased above as of this tag.
### Changed
- **Releases move to Gitea.** A `v<CLI version>` tag runs `.gitea/workflows/release.yaml`: the
`scripts/release.mjs` gate, the Linux, macOS arm64 (native) and Windows x64 (native) binaries, the npm
packages on `https://git.kof22.com/api/packages/QRun-IO/npm/`, the Gitea release, and for final versions
the Homebrew tap and Scoop bucket. The `cli-v*` tags and GitHub releases are retired; `scripts/install.sh`,
the formula and the Scoop manifest download from `https://git.kof22.com/QRun-IO/qqq-cli/releases`.
### Fixed
- **Windows: the DPAPI keyring works when `qqq` is started from PowerShell 7.** The inherited `PSModulePath`
named PowerShell 7's modules, so Windows PowerShell 5.1 could not load `Microsoft.PowerShell.Security` and every
sign-in failed with "Windows credential protection failed". The keyring now starts PowerShell without it.
Found by the first native Windows release lane.
## [1.0.0] - 2026-11-01
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
+2 -2
View File
@@ -324,8 +324,8 @@ scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077
```
The CLI runs on Node 20 and later and uses no Bun-only API; Bun is needed only for the TUI and the
binaries. CI is the shared Gitea `node.yaml` workflow (`.gitea/workflows/build.yaml`): build, test, lint and
a generated-code drift check on every push; it publishes nothing ([`docs/ci-lanes.md`](docs/ci-lanes.md)).
binaries. CI is the qqq-orb `cli` profile (`.qqq-ci.yml`); a tag `v<client version>` publishes the
packages to GitHub Packages ([`docs/ci-lanes.md`](docs/ci-lanes.md) says what it does and does not cover).
## Contributing, security, license
+2 -2
View File
@@ -23,9 +23,9 @@ Everything under `docs/`:
| [release-notes/1.0.0.md](release-notes/1.0.0.md) | the GitHub release body for `cli-v1.0.0` (the release commands are in an HTML comment at the top) |
| [release-notes/1.0.0-rc.1.md](release-notes/1.0.0-rc.1.md) | the GitHub release body for the release candidate `cli-v1.0.0-rc.1`, kept for history |
| [packaging/README.md](packaging/README.md) | the install paths (curl installer, Homebrew, Scoop, npm), where the formula and manifest live, platform notes (Gatekeeper, SmartScreen, musl), and how the terminal UI is bundled |
| [packaging/homebrew-tap/](packaging/homebrew-tap/README.md) | the complete `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb`, README, LICENSE (its old qqq-orb CI files were removed with CircleCI) |
| [packaging/homebrew-tap/](packaging/homebrew-tap/README.md) | the complete `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb`, README, LICENSE and its qqq-orb CI files |
| [packaging/qqq.json](packaging/qqq.json) | the Scoop manifest, copied to `bucket/qqq.json` in `QRun-IO/scoop-bucket` |
| [ci-lanes.md](ci-lanes.md) | what CI runs (the shared Gitea `node.yaml` workflow), what is outside CI, and the history of the retired CircleCI orb lanes |
| [ci-lanes.md](ci-lanes.md) | what CircleCI (the qqq-orb `cli` profile) runs: the Linux verify-and-publish job and the macOS arm64 and Windows x64 lanes, what is still outside CI, and the history of the orb changes |
`scripts/update-packaging.mjs` fills the version and checksums of the formula and manifest from a
release's `SHA256SUMS`.
+21 -10
View File
@@ -2,19 +2,30 @@
## What runs today
CircleCI and the qqq-orb were retired on 2026-11-07. CI is the shared Gitea workflow
[`KofTwentyTwo/ci-workflows` `node.yaml`](https://git.kof22.com/KofTwentyTwo/ci-workflows), called from
`.gitea/workflows/build.yaml` on every push, Linux only (`node:22-bookworm`):
`.circleci/config.yml` is the qqq-orb setup stub on the `0.8` track (it picks up every 0.8.x release by
itself); `.qqq-ci.yml` selects the orb's `cli` profile and, since qqq-orb **0.8.33**, turns on the
platform lanes with `platforms: [macos-arm64, windows-x64]`. The profile generates three jobs:
1. `npm ci`
2. `npm run generate`, then `git diff --exit-code` on `schema.ts` and `operations.ts` (generated-code drift)
3. `npm run build`, `npm test`, `npm run lint`
| Job | Executor | What it runs |
|---|---|---|
| `cli-macos-arm64` (`qqq-orb/cli_platform`) | macOS, Xcode 16.4, `m4pro.medium` | Node 20.20.2 (the `engines` floor) with nvm, `npm ci`, `npm run build && npm test && npm run lint` |
| `cli-windows-x64` (`qqq-orb/cli_platform`) | Windows Server 2022, `windows.medium`, steps in Git Bash | the same, on Windows (keyring DPAPI path, Windows paths) |
| `qqq-orb/cli_verify_publish` | Linux, `cimg/node:22.23` | `npm ci`; `npm run generate` then `git diff --exit-code` on `schema.ts` and `operations.ts`; build, test, lint; a Prism smoke (`GET /qqq/v1/version`, `/metaData/authentication`); on a tag `v<client version>` with `publish.release: true`, publishing (below) |
Not in CI: publishing (packages, binaries, Homebrew/Scoop updates are release-machine steps in
`docs/release-runbook.md` until a release workflow is decided), the macOS arm64 and Windows x64 lanes,
the Prism smoke, the TUI (`npm run test:tui`, `typecheck:tui`, which need Bun) and the platform binaries.
On branches and pull requests the three jobs run side by side. On a release tag `cli_verify_publish`
**requires both platform lanes**, so nothing is published unless macOS and Windows are green. Publishing
puts all four packages on GitHub Packages (client, core, conformance, CLI, in that order) with the dist-tag
`latest` when all four versions are final and `rc` when any of them has a prerelease part. The CircleCI
plan must include the `m4pro.medium` and `windows.medium` resource classes.
Everything below describes the retired CircleCI setup and is kept for the record.
The orb's generator accepts this manifest: rendered with `src/scripts/generate_pipeline.py` from qqq-orb
v0.8.33, a push to `main` gives `cli-macos-arm64`, `cli-windows-x64` and `cli_verify_publish` in
`test_only`, and the tag `v1.8.0` gives the same in `publish_release` with `cli_verify_publish` requiring
both lanes and bound to the `qqq-maven-registry-credentials` context.
Still not in CI: the TUI (`npm run test:tui`, `typecheck:tui`, which need Bun), and building and smoking
the platform binaries (`scripts/build-binaries.mjs --host`, `scripts/verify-binary.sh` / `.ps1`); the
lanes run the Node packages only. Those stay release-machine steps in `docs/release-runbook.md`.
### History
+1 -1
View File
@@ -17,7 +17,7 @@ Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/op
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `feat/per-session-presence` 0ae1e62 (local worktree `.worktrees/qbit-collab-sessions`, not yet on `develop`) | 1.1.0 (presence schemas, error contract); 1.2.0 adds per-connection presence (`connections`, `leaseCount`, `perConnection`), used when present |
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
+4 -2
View File
@@ -36,8 +36,10 @@ checkpoint the sums here first, not there.
## Where the formula and manifest live
- **`QRun-IO/homebrew-tap`** is laid out under `homebrew-tap/` here (the repository is created by copying
that directory). Its old CI (the qqq-orb `homebrew-tap` profile on CircleCI) was retired on
2026-11-07 and the seed no longer carries CI files; tap CI on Gitea is not decided yet. qctl stays in `QRun-IO/homebrew-qctl` until it
that directory). Its CI is the qqq-orb `homebrew-tap` profile (`.qqq-ci.yml`, with the same setup
`.circleci/config.yml` as `QRun-IO/homebrew-qctl`). That CI fetches and checksums every platform's
download, but it installs the formula and runs `brew test` with the **Linux x64 binary only**; the
macOS (and Linux arm64) binaries are not executed there. qctl stays in `QRun-IO/homebrew-qctl` until it
moves over.
- **`QRun-IO/scoop-bucket`** is created from
[ScoopInstaller/BucketTemplate](https://github.com/ScoopInstaller/BucketTemplate), with `qqq.json` as
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
ignore: /.*/
+5
View File
@@ -0,0 +1,5 @@
profile: homebrew-tap
orb_version: '0.8'
default_branch: main
tap: qrun-io/tap
formula: qqq
+6 -6
View File
@@ -2,31 +2,31 @@
# qqq: the QQQ command-line client and terminal UI (qqq ui) in one binary.
# Kept in qqq-cli at docs/packaging/homebrew-tap/Formula/qqq.rb; scripts/update-packaging.mjs fills
# version and sha256 from the SHA256SUMS of a Gitea release; the release workflow then copies it to the tap.
# version and sha256 from the SHA256SUMS of a GitHub release, then the file is copied to the tap.
class Qqq < Formula
desc "Command-line client and terminal UI for QQQ applications"
homepage "https://git.kof22.com/QRun-IO/qqq-cli"
homepage "https://github.com/QRun-IO/qqq-cli"
version "1.0.0"
license "Apache-2.0"
on_macos do
on_arm do
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-arm64"
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
end
on_intel do
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-x64"
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
end
end
on_linux do
on_arm do
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-arm64"
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
end
on_intel do
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-x64"
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
end
end
+3 -3
View File
@@ -20,9 +20,9 @@ here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a po
The formula is generated in qqq-cli: after a `cli-v<version>` GitHub release, run
`node scripts/update-packaging.mjs <release SHA256SUMS>` there and copy
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. The tap's old CI (qqq-orb `homebrew-tap` profile,
which installed the formula and ran its test) was retired on 2026-11-07; Gitea CI for the tap is not
decided yet, so check the formula by hand (`brew install --build-from-source ./Formula/qqq.rb && brew test qqq`). The script refuses to write missing or
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every
platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but
only once the pull request here is opened and its checks are read. The script refuses to write missing or
malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR.
Apache-2.0
+5 -6
View File
@@ -1,7 +1,7 @@
{
"version": "1.0.0",
"description": "Command-line client and terminal UI (qqq ui) for QQQ applications, in one binary",
"homepage": "https://git.kof22.com/QRun-IO/qqq-cli",
"homepage": "https://github.com/QRun-IO/qqq-cli",
"license": "Apache-2.0",
"notes": [
"qqq ui (the terminal UI) needs Windows Terminal or another ConPTY terminal; the legacy console is not supported.",
@@ -9,7 +9,7 @@
],
"architecture": {
"64bit": {
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.0.0/qqq-windows-x64.exe",
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
}
},
@@ -20,14 +20,13 @@
]
],
"checkver": {
"url": "https://git.kof22.com/api/v1/repos/QRun-IO/qqq-cli/releases/latest",
"jsonpath": "$.tag_name",
"regex": "v([\\d.]+)"
"github": "https://github.com/QRun-IO/qqq-cli",
"regex": "cli-v([\\d.]+(?:-rc\\.\\d+)?)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v$version/qqq-windows-x64.exe",
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v$version/qqq-windows-x64.exe",
"hash": {
"url": "$baseurl/SHA256SUMS"
}
+194 -94
View File
@@ -1,140 +1,240 @@
# Release runbook
# Release runbook: qqq 1.0.0
From "main is green" to "qqq X.Y.Z is published and installable everywhere". A release is one tag,
`v<CLI version>`, on the merge commit; Gitea CI (`.gitea/workflows/release.yaml`) does the rest. Nothing
publishes from a branch.
> **Since 2026-11-08** releases run on Gitea. The CircleCI/qqq-orb path (npm packages on a
> `v<client version>` tag, binaries on a GitHub release `cli-v<version>`, hand-made tap and bucket pull
> requests) is retired. Older releases (1.0.0 and before) stay on GitHub.
From "the branch is green" to "1.0.0 is published and installable everywhere". It is written for
1.0.0 but holds for any release; replace the versions. Nothing in the repository tags, pushes or
publishes by itself: `scripts/release.mjs` checks and prints, people run the commands.
What ships:
| Artifact | Where | Made by |
|---|---|---|
| `qqq` binaries with the terminal UI, 5 targets (linux x64/arm64, darwin x64/arm64, windows x64), plus `SHA256SUMS` | Gitea release `v<version>` of `QRun-IO/qqq-cli` | `release.yaml`: Linux targets and darwin-x64 (cross) in the container, darwin-arm64 on the macOS runner, windows-x64 on the Windows runner |
| `@qrun-io/qqq-ui-api-client`, `qqq-core`, `qqq-ui-api-conformance`, `qqq-cli` | QRun-IO Gitea npm registry, `https://git.kof22.com/api/packages/QRun-IO/npm/` | `release.yaml` publish job |
| Homebrew formula `qqq` | `QRun-IO/homebrew-tap`, `Formula/qqq.rb` | pull request from `release.yaml` (final versions), merged by a human |
| Scoop manifest `qqq` | `QRun-IO/scoop-bucket`, `bucket/qqq.json` | pull request from `release.yaml` (final versions), merged by a human |
| Artifact | Version for 1.0 | Where | Made by |
|---|---|---|---|
| `qqq` binaries with the terminal UI, 7 targets, plus `SHA256SUMS` | 1.0.0 | GitHub release `cli-v1.0.0` | `scripts/build-binaries.mjs` (via `release.mjs`) |
| `@qrun-io/qqq-cli` (CLI only, Node 20+) | 1.0.0 | GitHub Packages | the orb, on tag `v1.8.0` |
| `@qrun-io/qqq-core` | 1.0.0 | GitHub Packages | the orb (with PR #165), else by hand |
| `@qrun-io/qqq-ui-api-client` | 1.8.0 | GitHub Packages | the orb |
| `@qrun-io/qqq-ui-api-conformance` | 1.6.0 | GitHub Packages | the orb |
## 1. Versions
**All four npm packages go final together.** The orb (PR #165) derives the dist-tag from all four
versions: if any of client, core, conformance or CLI has a prerelease part, all four are published as
`rc`; only when all four are final do they go out as `latest`. So 1.0.0 ships client 1.8.0 and
conformance 1.6.0 as finals beside CLI and core 1.0.0, under the tags `v1.8.0` (orb publish) and
`cli-v1.0.0` (GitHub release). `release.mjs` refuses a final CLI version while any of the four is still a
prerelease (the readiness gate's "package versions" line).
| Homebrew formula `qqq` | 1.0.0 | `QRun-IO/homebrew-tap` | `scripts/update-packaging.mjs`, then a PR |
| Scoop manifest `qqq` | 1.0.0 | `QRun-IO/scoop-bucket` | `scripts/update-packaging.mjs`, then a PR |
On a branch from `main`:
## Checklist: what needs James
| File | Change |
|---|---|
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | the CLI version, e.g. `1.1.0-rc.1` or `1.1.0` |
| `packages/core/package.json`, `packages/conformance/package.json` | bump any package whose content changed |
| `packages/ui-api-client/package.json` | follows the UI API version of `openapi/qqq-v1.json` |
| workspace dependencies in `packages/cli` and `packages/core` | the same versions, exact |
| `CHANGELOG.md` | a `## [X.Y.Z] - <date>` entry (it becomes the release notes; `docs/release-notes/X.Y.Z.md` wins if present) |
Nobody else can do these; each says what happens without it.
Then `npm install` (refreshes `package-lock.json`), `npm run lint` (fails if any version disagrees) and
`node scripts/release.mjs` (the same gate CI runs; add `--skip-binaries` to save time). A final version
must pass the readiness gate (`npm run release -- --check`: parity, findings filed, CHANGELOG entry,
documented commands, all four packages final); a prerelease is not gated. Merge through a pull request.
- [x] **Orb changes released** in `kingsrook/qqq-orb` 0.8.33 (`docs/ci-lanes.md`): publishing
`@qrun-io/qqq-core`, lint, the `operations.ts` drift check, the dist-tag rule above (PR #165), and
the macOS arm64 and Windows x64 lanes, which `.qqq-ci.yml` turns on (`platforms:`) and a release tag
requires before publishing.
- [ ] **CircleCI plan** includes the `m4pro.medium` (macOS) and `windows.medium` resource classes. Without
them the platform lanes cannot start, and because a release tag requires them, the publish waits;
remove `platforms:` from `.qqq-ci.yml` to publish without them.
- [ ] **Apple Developer ID and notarization**: a "Developer ID Application" certificate and a notary
credential (App Store Connect API key) on the release machine. Without it the macOS binaries are
only ad-hoc signed. Homebrew and the curl installer are not affected (they do not set the
quarantine flag), but a binary downloaded with a browser is stopped by Gatekeeper ("Apple cannot
check it for malicious software"). Workaround, documented in the README and the packaging notes:
`xattr -d com.apple.quarantine ./qqq-darwin-arm64`, or Finder, right-click, Open.
- [ ] **Windows Authenticode certificate** (the KofTwentyTwo release standard). Without it SmartScreen
warns on first run ("Windows protected your PC", More info, Run anyway); Scoop installs still work.
- [x] **`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` exist** (created from `docs/packaging/homebrew-tap/` and
`docs/packaging/qqq.json`). After the GitHub release, run `scripts/update-packaging.mjs` on the uploaded
`SHA256SUMS` and commit the filled `Formula/qqq.rb` and `bucket/qqq.json` to them. Without that commit
`brew install` and `scoop install` fail their checksum (the curl installer and the download still work).
- [ ] **CircleCI context** `qqq-maven-registry-credentials` holds a `GITHUB_TOKEN` that can write the
four `@qrun-io` packages (the orb checks it authenticates; write access is only proven by a publish).
- [ ] **Go for 1.0.0 final**: the 1.0.0-rc.1 feedback is accepted and no blocker is open.
Each package version is published once. The publish job skips a package whose exact version is already
in the registry (unchanged packages ride along), so bump every package whose content changed.
## Not verified before 1.0
## 2. Tag
The platform lanes run the Node packages' build, tests and lint on macOS arm64 and Windows x64 (Node
20), which covers the keyring code paths there, but they do not build or run the binaries.
- `qqq-windows-x64.exe` (CLI and UI) has never run; `scripts/verify-binary.ps1` is the smoke to run on
a Windows machine.
- `qqq-darwin-x64` has never run (no Intel Mac or Rosetta on the build machine).
- Windows Terminal rendering of `qqq ui`, and the DPAPI keyring outside unit tests.
Verified on the build machine: darwin-arm64 (CLI, keyring, completion, `qqq ui` tmux smoke and width
smoke against the sample server), linux-x64 glibc, linux-arm64 glibc and linux-arm64-musl in Docker
(CLI and `qqq ui` in tmux), the npm tarballs installed on Node 20, the Homebrew formula in the
`homebrew/brew` container, and the curl installer from a local mirror.
## 1. Branch green
- `feat/m5-cli-release` (and whatever else is in the release) is merged to `main`; the orb pipeline on
`main` is green: `cli_verify_publish` (Linux, `cimg/node:22.23`) and the `cli-macos-arm64` and
`cli-windows-x64` lanes.
- Locally on `main`, clean tree: `npm ci && npm run build && npm test && npm run lint && npm run test:tui`.
## 2. Versions: release candidate to final
**For 1.0.0 this step is done** on the branch `release/1.0.0` (34bbd2a, on top of the rc.1 commit
6517373): all four packages final, the exact workspace pins, the `## [1.0.0]` CHANGELOG entry,
`docs/release-notes/1.0.0.md`, and the READMEs at 1.0.0. That branch (with the release docs merged into
it) is fast-forwarded onto `main`; nothing else changes versions:
```sh
git checkout main && git pull --ff-only
git tag -s v1.1.0 -m "qqq 1.1.0" && git push origin v1.1.0
git merge --ff-only release/1.0.0 # fails if main moved: rebase release/1.0.0 onto main first
git push origin main
```
Or create the tag on the merge commit through the Gitea API (`POST /repos/QRun-IO/qqq-cli/tags`). The tag
must equal `packages/cli/package.json` or the release fails at once. A version with a `-` (`-rc.N`) is a
prerelease: npm dist-tag `next` for prerelease packages, a Gitea prerelease, and no tap or bucket update.
For later releases, the table below is the recipe.
## 3. What CI does
On a branch `release/1.0.0` from `main`:
`.gitea/workflows/release.yaml`, in order; any failure stops it:
| File | Change |
|---|---|
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | `1.0.0-rc.N` → `1.0.0` |
| `packages/core/package.json` | `1.0.0-rc.N` → `1.0.0` |
| `packages/ui-api-client/package.json` | `1.8.0-rc.N` → `1.8.0` (the client follows the UI API version) |
| `packages/conformance/package.json` | `1.6.0-rc.N` → `1.6.0` |
| workspace dependencies in `packages/cli` and `packages/core` | the same versions, exact |
| `CHANGELOG.md` | a `## [1.0.0] - <date>` entry above the rc entries |
| `README.md`, `packages/cli/README.md` | the version and `@rc` in the npm command (`@latest`, or no tag) |
1. **linux** (`container-build`, `node:22.23.4-bookworm`, Bun 1.4.4 checksum-pinned):
`scripts/release.mjs --skip-binaries` (npm ci, generate drift, build, readiness for finals, tests, lint,
TUI tests and type check, pack dry runs), then the linux-x64, linux-arm64 and darwin-x64
binaries (`scripts/build-binaries.mjs`), the `scripts/verify-binary.sh` smoke on linux-x64, and a
**draft** Gitea release with those binaries. An existing release for the tag fails the run.
2. **macos** (`qqq-macos-arm64`) and **windows** (`qqq-windows-vm6218`), in parallel: pinned Node.js
(`SHASUMS256.txt`) and Bun (pinned sha256), `npm ci`, build, test, lint, the native binary
(`--host`), the `verify-binary.sh` / `verify-binary.ps1` smoke, and the upload to the draft.
3. **publish** (`container-build`): `SHA256SUMS` from the three jobs' checksums, uploaded; the release is
published and every download is checked against `SHA256SUMS`; the four packages go to the Gitea npm
registry with `PACKAGES_TOKEN`; `scripts/update-packaging.mjs` fills the formula and the manifest.
For a final version `scripts/publish-packaging.sh` pushes branch `release/qqq-<version>` to
`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` with `PACKAGES_TOKEN` and opens a pull request
`qqq <version>` in each; a human checks the sums and merges. When the push or the pull request is refused
(or for a prerelease) `qqq.rb` and `qqq.json` are attached to the release instead (step 4); the release
never fails over this. Dry run, no writes: `DRY_RUN=1 VERSION=<version> SUMS_FILE=<SHA256SUMS> scripts/publish-packaging.sh`.
All four packages move to finals in the same commit; none may stay `-rc` (the orb would then publish all
four as `rc`, and `release.mjs` refuses). Then `npm install` (refreshes `package-lock.json`),
`npm run lint`, which fails if any version disagrees, and `npm run release -- --check`, which must say
READY (parity 100% in both columns, every finding filed, the CHANGELOG entry, documented commands,
package versions). Commit (`git commit -S -m "release: 1.0.0"`), merge to `main` through a pull request.
Watch it under Actions in `QRun-IO/qqq-cli`. The release page is
`https://git.kof22.com/QRun-IO/qqq-cli/releases/tag/v<version>`.
## 3. Release check and binaries
**No musl binaries.** `build-binaries.mjs --musl` is best effort: on the glibc build container Bun does not
install OpenTUI's musl native packages, so the release carries no `qqq-linux-*-musl`. On Alpine and other
musl hosts use the npm package (`@qrun-io/qqq-cli`, CLI only) until a musl build lane exists.
## 4. Homebrew tap and Scoop bucket (when CI could not push)
`PACKAGES_TOKEN` is the package-scoped publisher token (user `qqq-package-publisher`); unless it is granted
write on the tap and the bucket, CI attaches the filled files to the release instead. Their `main` branches
should be protected so CI can only propose. For a final version, download them and open a
pull request on each:
On `main` at the release commit, clean tree:
```sh
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.rb
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.json
# QRun-IO/homebrew-tap: Formula/qqq.rb QRun-IO/scoop-bucket: bucket/qqq.json
node scripts/release.mjs # the readiness gate (final versions), npm ci, generate drift, build, test,
# lint, TUI tests and type check, pack dry runs, the 7 binaries with the UI
# and dist/SHA256SUMS
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # sample server running
```
Compare each sum with the release's `SHA256SUMS`. A wrong sum makes `brew install` and `scoop install`
fail their checksum; the curl installer and direct downloads are unaffected.
`release.mjs` prints the commands for steps 5 and 6 with the right versions.
## 5. Signing (not automated)
## 4. Sign (James's credentials)
The binaries are not signed by CI: there is no Apple Developer ID / notary credential or Authenticode
certificate on the runners. Homebrew and the curl installer do not quarantine, so they work unsigned; a
browser-downloaded macOS binary needs `xattr -d com.apple.quarantine ./qqq-darwin-arm64`, and Windows
SmartScreen warns on first run. `scripts/sign-macos.sh` and `scripts/sign-windows.sh` remain for a manual
signed release (they recompute `dist/SHA256SUMS`); wiring them into the native jobs needs the credentials
as runner-side secrets (owner decision).
Signing changes the files, so it comes before the checksums are final. Each script reads its
credentials from the environment, does nothing (and says what is missing) without them, has
`--dry-run`, and recomputes `dist/SHA256SUMS`.
Gitea has no equivalent of GitHub's artifact attestations; `SHA256SUMS` on the release and the signed tag
are the provenance.
```sh
QQQ_MACOS_SIGN_IDENTITY="Developer ID Application: <name> (<team>)" QQQ_NOTARY_PROFILE=qqq-notary \
scripts/sign-macos.sh # codesign, hardened runtime + scripts/macos-entitlements.plist, notarytool --wait
QQQ_WINDOWS_PFX=<cert.pfx> QQQ_WINDOWS_PFX_PASSWORD_FILE=<file> \
scripts/sign-windows.sh # osslsigncode with an RFC 3161 timestamp
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # still runs after signing
spctl --assess --type execute -vv dist/qqq-darwin-arm64 # "accepted, source=Notarized Developer ID"
```
## 6. Smoke after publishing
A bare executable cannot be stapled: Gatekeeper fetches the notarization ticket online the first time a
quarantined (browser-downloaded) copy runs, and refuses offline until it can. Homebrew and the curl
installer do not quarantine, so they work signed or not. Tried here without real credentials: an ad-hoc
hardened-runtime signature with the entitlements (the CLI and the `qqq ui` smoke still pass), and an
osslsigncode signature with a throwaway certificate that verifies. A real Developer ID, notarization
and an Authenticode certificate are untried until James's credentials are used.
## 5. Tags and packages
```sh
git tag -s v1.8.0 -m "UI API client 1.8.0, qqq 1.0.0" # the orb publishes on v<client version>
git tag -s cli-v1.0.0 -m "qqq 1.0.0" # the GitHub release with the binaries
git push origin v1.8.0 cli-v1.0.0
```
The tag `v1.8.0` makes the orb's `cli` profile verify the commit again and publish client, core,
conformance and CLI to GitHub Packages, all as `latest` because all four are final. Watch the
`publish_release` workflow. Only if the orb release with PR #165 is not out yet, finish by hand:
```sh
npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag latest
for p in qqq-ui-api-client qqq-core qqq-ui-api-conformance qqq-cli; do
npm dist-tag add "@qrun-io/$p@<its version>" latest --registry https://npm.pkg.github.com
done
```
A version can never be published twice: if the orb job fails after publishing some packages, bump the
patch version of the rest instead of retrying the same one.
## 6. GitHub release
```sh
gh release create cli-v1.0.0 --repo QRun-IO/qqq-cli --latest --title "qqq 1.0.0" \
--notes-file <(sed -n '/^## \[1\.0\.0\]/,/^## \[/p' CHANGELOG.md | sed '$d') \
dist/qqq-* dist/SHA256SUMS
```
`dist/qqq-*` is the seven binaries (source maps are in `dist/sourcemaps/`, not uploaded). For a release
candidate use `--prerelease` instead of `--latest`. The curl installer takes the newest stable `cli-v*`
release (the channel Homebrew and Scoop follow); a release candidate is reached with `--pre` or
`--version 1.x.y-rc.N`.
## 7. Homebrew tap and Scoop bucket
```sh
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
```
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
their checks.
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
CI fetches every platform's download, installs the formula and runs its test (which insists on
`binary with ui`). Scoop's `checkver`/`autoupdate` can take later versions from `SHA256SUMS` by itself.
## 8. Smoke after publishing
Against a real server (`--server`), from clean machines or containers:
```sh
# curl installer (macOS, Linux)
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh -s -- --version 1.1.0
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
scripts/verify-binary.sh ~/.local/bin/qqq --server https://<server>
# Homebrew
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq && scripts/verify-binary.sh "$(brew --prefix)/bin/qqq"
# npm, Node 20+
echo "@qrun-io:registry=https://git.kof22.com/api/packages/QRun-IO/npm/" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@1.1.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
# npm, Node 20
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@1.0.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
# Docker (glibc and musl)
docker run --rm --platform linux/amd64 -v "$PWD/dist:/d:ro" -v "$PWD/scripts:/s:ro" debian:bookworm-slim bash /s/verify-binary.sh /d/qqq-linux-x64
```
```powershell
scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket; scoop install qqq
# Windows, PowerShell
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq
pwsh scripts\verify-binary.ps1 -Binary (Get-Command qqq).Source -Server https://<server>
```
Users who added the tap or the bucket from GitHub re-add them from Gitea
(`brew tap qrun-io/tap https://git.kof22.com/QRun-IO/homebrew-tap`).
And by hand once: download `qqq-darwin-arm64` with a browser on a Mac and run it (notarized: it runs;
not notarized: the Gatekeeper prompt, cleared by `xattr -d com.apple.quarantine`), then `qqq ui` in
Windows Terminal.
## 9. After
- Merge `main` back into the development branch; the next development version can stay at 1.0.0 until
the next bump (lint keeps the versions consistent either way).
- Announce with the release link and the install commands from the README.
## When something goes wrong
- **The run fails before the publish job**: nothing is public. Delete the draft release (and the tag, if
the version must change), fix, and tag again.
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` against the Gitea registry
and publish a new version; a version is never republished.
- **A binary is broken**: delete the release, bump the version and release again; the formula and the
manifest carry the sums, so never replace an asset in place.
- **The tap's or bucket's check fails on the new file**: do not merge; users keep the previous version.
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` and publish a patch; a
version cannot be unpublished from GitHub Packages without breaking installs that pinned it.
- **A binary is broken**: delete the asset from the GitHub release, rebuild, re-sign, upload, recompute
`SHA256SUMS` and rerun step 7 (the formula and manifest carry the sums).
- **The tap's CI fails on the new formula**: do not merge its pull request; `brew install` keeps
serving the previous version.
+18 -18
View File
@@ -14,8 +14,8 @@
],
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "6.0.3",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
},
"engines": {
@@ -564,13 +564,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"version": "22.15.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/swagger-schema-official": {
@@ -2708,9 +2708,9 @@
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2722,9 +2722,9 @@
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -2954,11 +2954,11 @@
},
"packages/cli": {
"name": "@qrun-io/qqq-cli",
"version": "1.0.1-rc.1",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-core": "1.0.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
},
@@ -2986,10 +2986,10 @@
},
"packages/core": {
"name": "@qrun-io/qqq-core",
"version": "1.0.1-rc.1",
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
@@ -3002,7 +3002,7 @@
},
"packages/ui-api-client": {
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.1-rc.1",
"version": "1.8.0",
"license": "Apache-2.0",
"dependencies": {
"openapi-fetch": "0.17.0"
+2 -2
View File
@@ -30,8 +30,8 @@
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "6.0.3",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-cli",
"version": "1.0.1-rc.1",
"version": "1.0.0",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"keywords": [
"qqq",
@@ -50,7 +50,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -58,8 +58,8 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-core": "1.0.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
}
+2 -2
View File
@@ -30,9 +30,9 @@ export interface BrowserSignInOptions {
/** Opens a URL with the platform's opener; failures are ignored (the URL is printed anyway). */
export function openInBrowser(url: string): void {
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? [process.env.QQQ_POWERSHELL || 'powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from('Start-Process -FilePath $env:QQQ_BROWSER_URL', 'utf16le').toString('base64')]] : ['xdg-open', [url]]
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
try {
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true, windowsHide: true, env: { ...process.env, QQQ_BROWSER_URL: url } })
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true })
child.on('error', () => undefined)
child.unref()
} catch {
+9 -23
View File
@@ -10,7 +10,7 @@
*/
import type { Command } from 'commander'
import { COLLABORATION, connectionsSuffix, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
@@ -27,15 +27,14 @@ function hhmmss(date: Date): string {
return date.toTimeString().slice(0, 8)
}
/** `2 on person 1: Alice Morgan ×2 (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
export function presenceLine(state: PresenceState): string {
if (!state.available) return 'presence: the collaboration extension is not installed'
if (state.error) return `presence: ${state.error}`
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
if (!state.count) return `nobody on ${where}`
const people = state.people.map((p) => `${p.name}${connectionsSuffix(p)} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
const connections = state.connections ?? state.count
return `${state.count} on ${where}${connections > state.count ? ` (${connections} connections)` : ''}: ${people.join(', ')}`
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
return `${state.count} on ${where}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
@@ -72,8 +71,7 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
.option('--watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
.option('--observe', 'follow changes without joining')
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
.option('--per-connection', 'one row per connection (tab, terminal) with its mode, field and start (collaboration 1.2.0)')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean; perConnection?: boolean }) => {
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
@@ -83,30 +81,18 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
}
if (!options.watch && !options.observe) {
const state = await collaboration.presenceSnapshot(scope, { perConnection: options.perConnection })
const connections = state.connections ?? state.count
const where = `on ${pk !== undefined ? `${table} ${pk}` : table}`
const notes = [where, ...(connections > state.count ? [`${connections} connections`] : []), '--watch to join and follow']
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', connections: p.connections ?? 1 }))
if (options.perConnection) {
// older servers send no per-connection detail: one row per person, connection unknown
const leaseRows = state.people.flatMap((p) => p.leases?.length
? p.leases.map((l) => ({ name: p.name, user: p.userId, mode: l.mode, field: l.fieldName ?? '', since: l.since ? hhmmss(l.since) : '', connection: l.connectionId }))
: [{ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', since: '', connection: '' }])
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows, leases: leaseRows }, rows: leaseRows, columns: ['name', 'user', 'mode', 'field', 'since', 'connection'], footer: countFooter(state.count, 'person', notes, 'people') })
return
}
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field', 'connections'], footer: countFooter(state.count, 'person', notes, 'people') })
const state = await collaboration.presenceSnapshot(scope)
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
perConnection: options.perConnection,
mode: options.editing ? 'EDITING' : 'VIEWING',
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
onChange: (state) => {
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName, p.connections, p.leases?.map((l) => [l.connectionId, l.mode, l.fieldName])]), state.error, state.live])
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
@@ -1,33 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import childProcess from 'node:child_process'
import { syncBuiltinESMExports } from 'node:module'
import { EventEmitter } from 'node:events'
import { openInBrowser } from '../browser-login.js'
test('Windows browser opener preserves OAuth query parameters without shell interpretation', { skip: process.platform !== 'win32' }, () => {
const original = childProcess.spawn
const url = 'https://example.invalid/authorize?response_type=code&client_id=fixture-native&state=fake%20state&redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback'
let captured: unknown[] = []
childProcess.spawn = ((...args: unknown[]) => {
captured = args
return Object.assign(new EventEmitter(), { unref() {} })
}) as typeof childProcess.spawn
syncBuiltinESMExports()
try { openInBrowser(url) } finally { childProcess.spawn = original; syncBuiltinESMExports() }
const [command, args, options] = captured as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean }]
assert.notEqual(command, 'cmd')
assert.equal(options.env.QQQ_BROWSER_URL, url)
assert.equal(options.windowsHide, true)
const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le')
const probe = 'function Start-Process { param([string]$FilePath) [Console]::Write($FilePath) }; ' + script
const result = childProcess.spawnSync(command, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(probe, 'utf16le').toString('base64')], { env: options.env, encoding: 'utf8', windowsHide: true })
assert.equal(result.status, 0, result.stderr)
assert.equal(result.stdout, url)
})
-1
View File
@@ -41,5 +41,4 @@ test('presence lines say who is there and since when', () => {
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 1, connections: 2, people: [{ userId: 'admin', name: 'Admin', mode: 'VIEWING', since, connections: 2 }], live: true }), '1 on person 1 (2 connections): Admin ×2 (viewing since 17:50:01)')
})
+1 -1
View File
@@ -4,7 +4,7 @@ esb qqq esb 1.1.0 ok 1.1.0 1.1.0 5
health qqq health not installed 1.1.0 1.1.0 4
alerts qqq alerts not installed 1.2.0 6
assistant qqq assistant not installed 1.6.0 16
collaboration qqq collab not installed 1.2.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
collaboration qqq collab not installed 1.1.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
data-analysis qqq data-analysis not installed 1.5.0 21
db-performance qqq db-performance not installed 1.0.0 4
edi qqq edi not installed 1.1.0 4
@@ -1,68 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq collab presence` against a stub: the connections column for two terminals of one user
* (collaboration 1.2.0), `--per-connection` rows, and a 1.1.0 server that sends no connections.
*/
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
import { startStub, type Stub } from './stub.js'
describe('qqq collab presence against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
let version = '1.2.0'
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
const admin = { userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }
before(async () => {
box = sandbox()
stub = await startStub([
[/^GET \/version$/, () => ({ apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'collaboration', version }] })],
[/^GET \/qbit\/collaboration\/presence$/, (r) => {
if (version === '1.1.0') return { table: 'person', viewers: [admin, { userId: 'bob', displayName: 'Bob', mode: 'EDITING' }] }
const leases = [
{ ...admin, connections: 1, connectionId: 'c0000000000000001', sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ ...admin, mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'c0000000000000002', sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
return { table: 'person', viewers: [{ ...admin, mode: 'EDITING', connections: 2 }], leaseCount: 2, ...(r.query.get('perConnection') === 'true' ? { leases } : {}) }
}],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('two terminals of one user: one person with 2 connections', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^name,user,mode,field,connections\r?\nAdmin,admin,EDITING,,2/m)
const json = JSON.parse((await qqq(['collab', 'presence', 'person', '-O', 'json'])).stdout)
assert.deepEqual([json.count, json.connections, json.viewers[0].connections], [1, 2, 2])
assert.equal(stub.requests.filter((q) => q.path === '/qbit/collaboration/presence').at(-1)!.query.get('perConnection'), null)
})
test('--per-connection lists each terminal with its mode, field and start', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])
assert.equal(r.code, 0, r.stderr)
const json = JSON.parse(r.stdout)
assert.deepEqual(json.leases.map((l: { mode: string; field: string; connection: string }) => [l.mode, l.field, l.connection]), [['VIEWING', '', 'c0000000000000001'], ['EDITING', 'name', 'c0000000000000002']])
assert.equal(stub.requests.at(-1)!.query.get('perConnection'), 'true')
})
test('a collaboration 1.1.0 server without connections: each person counts once', async () => {
version = '1.1.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /Admin,admin,VIEWING,,1\r?\nBob,bob,EDITING,,1/)
const per = JSON.parse((await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])).stdout)
assert.deepEqual([per.count, per.connections, per.leases.length], [2, 2, 2])
})
})
+1 -2
View File
@@ -18,7 +18,6 @@ import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
import { CLI_VERSION } from '../version.js'
type J = Record<string, unknown>
@@ -267,7 +266,7 @@ describe('qqq print and qqq printer', () => {
assert.deepEqual(filter.orderBys, [{ fieldName: 'id', isAscending: false }])
assert.equal(run.values.filterDescription, 'lastName EQUALS Odd')
// the app header and footer
assert.equal(String(run.values.footerLeft).replace(/:\d+$/, ':PORT'), `qqq ${CLI_VERSION} · 127.0.0.1:PORT`)
assert.match(String(run.values.footerLeft), /^qqq \d+\.\d+\.\d+ · 127\.0\.0\.1:\d+$/)
assert.match(String(run.values.headerServer), /^127\.0\.0\.1:\d+$/)
assert.equal(run.values.headerTitle, 'Person')
assert.match(String(run.values.headerPrintedAt), /\d/)
+1 -1
View File
@@ -18,7 +18,7 @@ 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.1-rc.1'
export const CLI_VERSION = '1.0.0'
/** 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'
+1 -1
View File
@@ -39,7 +39,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
+1 -1
View File
@@ -30,7 +30,7 @@ test('on a MOCK server without a token, every authenticated check (jobs too) use
const seen: Array<{ path: string; authorization?: string; cookie?: string }> = []
const results = await runConformance({ baseUrl: 'http://fake', fetch: fakeServer(seen) })
const jobs = results.find((r) => r.name === 'GET /qqq/v1/jobs validates')!
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail ?? 'jobs check did not pass')
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail)
assert.equal(seen.find((s) => s.path === '/qqq/v1/metaData')?.cookie, 'sessionUUID=s-1')
assert.equal(seen.find((s) => s.path === '/qqq/v1/jobs')?.cookie, 'sessionUUID=s-1')
assert.match(results.find((r) => r.name === 'GET /qqq/v1/metaData validates')!.detail!, /authenticated with a server session/)
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-core",
"version": "1.0.1-rc.1",
"version": "1.0.0",
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
"keywords": [
"qqq",
@@ -42,7 +42,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -50,7 +50,7 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
+2 -2
View File
@@ -34,7 +34,7 @@ export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
{"name":"collaboration","version":"1.2.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
@@ -111,7 +111,7 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk","perConnection"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
-7
View File
@@ -43,7 +43,6 @@ const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
announce: true, // false: watch without appearing
})
presence.count; presence.people; presence.state.live; presence.state.error
presence.state.connections // two terminals of one user count twice (collaboration 1.2.0)
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
```
@@ -56,12 +55,6 @@ await presence.stop() // DELETE /presence, stop the heartbeat, close the s
- `since` is when this client first saw the person; the qbit does not send join times.
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
EDITING wins).
- Collaboration 1.2.0: each person has `connections` (their live tabs and terminals) and
`state.connections` totals them. `perConnection: true` (option of `joinPresence` and
`presenceSnapshot`) also fills `person.leases`: each connection's mode, field, start and opaque
ids, this client's own marked `self` (the server's id is `c` + 16 hex digits of SHA-256 of
user id, newline, connection id; see `opaqueConnectionId`). Older servers send neither: each
person is then one connection and `leases` stays unset.
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
`openStream` that fans out its own connection's events instead of opening another.
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
+16 -94
View File
@@ -17,7 +17,6 @@
* there) and the other calls throw ExtensionNotInstalledError.
*/
import { createHash } from 'node:crypto'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import type { ExtensionRegistry } from './registry.js'
@@ -105,20 +104,6 @@ export interface PresenceScope {
pk?: string
}
/** One live connection (a tab or terminal) of a person, from collaboration 1.2.0's per-connection presence. */
export interface PresenceConnection {
/** Opaque per-connection id from the server (never the client's own connection id). */
connectionId: string
mode: 'VIEWING' | 'EDITING' | string
fieldName?: string | null
/** When the lease started, as the server reports it. */
since?: Date
/** Opaque id of the signed-in session behind it (never the session itself). */
sessionId?: string
/** True for this client's own connection. */
self?: boolean
}
/** One person on the scope, merged across their tabs. */
export interface PresencePerson {
userId: string
@@ -127,10 +112,6 @@ export interface PresencePerson {
fieldName?: string | null
/** When this client first saw them there (the qbit does not send join times). */
since: Date
/** Live connections (tabs, terminals) merged into this person; 1 from servers before collaboration 1.2.0 (always set by this module). */
connections?: number
/** Each connection, when the server sent per-connection presence (perConnection, or any 1.2.0 event). */
leases?: PresenceConnection[]
}
export interface PresenceState {
@@ -139,8 +120,6 @@ export interface PresenceState {
scope: PresenceScope
count: number
people: PresencePerson[]
/** Live connections behind `people` (two terminals of one user count twice); equals `count` on older servers (always set by this module). */
connections?: number
/** True while the realtime stream is connected and subscribed (else presence is polled). */
live: boolean
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
@@ -161,8 +140,6 @@ export interface PresenceOptions {
announce?: boolean
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
reconnectMs?: number
/** Ask for each connection's mode, field and start (collaboration 1.2.0; ignored by older servers). */
perConnection?: boolean
}
export interface PresenceHandle {
@@ -182,64 +159,12 @@ interface Viewer {
displayName?: string
mode?: string
fieldName?: string | null
connections?: number
connectionId?: string
sessionId?: string
since?: string
}
interface Snapshot {
table?: string
pk?: string | null
viewers?: Viewer[]
leaseCount?: number
leases?: Viewer[]
}
/**
* The opaque id the server gives a connection (collaboration 1.2.0): `c` and the first 16 hex
* digits of SHA-256 of the user id, a newline and the client's connection id.
*/
export function opaqueConnectionId(userId: string, connectionId: string): string {
return `c${createHash('sha256').update(`${userId}\n${connectionId}`).digest('hex').slice(0, 16)}`
}
/**
* People and connection totals from a snapshot or event. Servers before collaboration 1.2.0 send
* neither `connections` nor `leases`: each person then counts as one connection.
*/
export function presencePeople(snapshot: Snapshot, firstSeen: (userId: string) => Date, ownConnectionId?: string): { people: PresencePerson[]; connections: number } {
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const leases = new Map<string, PresenceConnection[]>()
for (const l of snapshot.leases ?? []) {
if (l.userId === undefined || !l.connectionId) continue
const userId = String(l.userId)
const since = l.since ? new Date(l.since) : undefined
const entry: PresenceConnection = {
connectionId: l.connectionId, mode: l.mode ?? 'VIEWING', fieldName: l.fieldName ?? null,
...(since && !Number.isNaN(since.getTime()) ? { since } : {}), ...(l.sessionId ? { sessionId: l.sessionId } : {}),
...(ownConnectionId && l.connectionId === opaqueConnectionId(userId, ownConnectionId) ? { self: true } : {}),
}
leases.set(userId, [...(leases.get(userId) ?? []), entry])
}
const people = viewers.map((v) => {
const userId = String(v.userId)
const own = leases.get(userId)
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : own?.length || 1
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen(userId), connections, ...(own ? { leases: own } : {}) }
})
const sum = people.reduce((n, p) => n + p.connections, 0)
return { people, connections: typeof snapshot.leaseCount === 'number' ? Math.max(snapshot.leaseCount, people.length) : sum }
}
/** ` ×2` after a person with more than one connection, else nothing. */
export function connectionsSuffix(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
/** GET /presence query for a scope; `perConnection` only when asked (older servers ignore it). */
function presenceQuery(scope: PresenceScope, perConnection?: boolean): Record<string, string | undefined> {
return { table: scope.table, pk: scope.pk, ...(perConnection ? { perConnection: 'true' } : {}) }
}
/** The channel presence events for a scope arrive on. */
@@ -356,15 +281,13 @@ export class Collaboration {
}
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
async presenceSnapshot(scope: PresenceScope, options: { perConnection?: boolean } = {}): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
if (!state.available) return state
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
const now = new Date()
const { people, connections } = presencePeople(snapshot, () => now)
state.people = people
state.count = people.length
state.connections = connections
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
state.count = state.people.length
return state
}
@@ -374,7 +297,7 @@ export class Collaboration {
* at once with a no-op handle when the extension is absent.
*/
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
if (!state.available) {
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
@@ -390,22 +313,22 @@ export class Collaboration {
const apply = (snapshot: Snapshot) => {
const now = new Date()
const ids = new Set((snapshot.viewers ?? []).filter((v) => v.userId !== undefined).map((v) => String(v.userId)))
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const ids = new Set(viewers.map((v) => String(v.userId)))
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
const { people, connections } = presencePeople(snapshot, (userId) => {
state.people = viewers.map((v) => {
const userId = String(v.userId)
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
return firstSeen.get(userId)!
}, announce ? connectionId : undefined)
state.people = people
state.count = people.length
state.connections = connections
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
})
state.count = state.people.length
state.error = undefined
notify()
}
const refresh = async () => {
try {
apply((await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot)
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
} catch (error) {
state.error = messageOf(error)
notify()
@@ -418,9 +341,8 @@ export class Collaboration {
try {
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
const data = result.data as Snapshot | undefined
// The qbit answers the heartbeat with the merged snapshot; older builds answer {success}.
// Per-connection detail only comes with GET, so re-read rather than drop it.
if (data && typeof data.table === 'string' && !options.perConnection) {
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
if (data && typeof data.table === 'string') {
apply(data)
return
}
+3 -21
View File
@@ -104,31 +104,13 @@ export class DpapiFileStore implements SecretStore {
}
private async ps(script: string, input: string): Promise<string> {
const shell = process.env.QQQ_POWERSHELL ?? 'powershell'
const command = "$ErrorActionPreference = 'Stop'; [Console]::InputEncoding = [Text.UTF8Encoding]::new($false); "
+ '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $qqqSecretInput = [Console]::In.ReadToEnd(); '
+ '$qqqResult = & { ' + script.replace('$INPUT', '$qqqSecretInput') + ' }; '
+ '[Console]::Write((ConvertTo-Json -InputObject $qqqResult -Compress))'
return new Promise<string>((resolve, reject) => {
const failed = () => reject(new Error('Windows credential protection failed; check PowerShell and your Windows profile'))
// Started from PowerShell 7, the inherited PSModulePath names PowerShell 7's modules, and Windows PowerShell 5.1
// then cannot load Microsoft.PowerShell.Security (ConvertTo-SecureString). Without it, each shell uses its own.
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.toUpperCase() !== 'PSMODULEPATH'))
const child = execFile(shell, ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000, env }, (error, stdout) => {
if (error) { failed(); return }
try {
const result: unknown = JSON.parse(stdout.trim())
if (typeof result !== 'string') { failed(); return }
resolve(result)
} catch { failed() }
})
child.stdin?.on('error', failed)
child.stdin?.end(input, 'utf8')
})
const { stdout } = await run(shell, ['-NoProfile', '-NonInteractive', '-Command', script.replace('$INPUT', JSON.stringify(input))])
return stdout.trim()
}
async get(account: string): Promise<string | undefined> {
const blob = this.read()[account]
if (!blob) return undefined
return this.ps('$s = ConvertTo-SecureString -String $INPUT; $p = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s); try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($p) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($p); $s.Dispose() }', blob)
return this.ps('$s = ConvertTo-SecureString -String $INPUT; [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', blob)
}
async set(account: string, secret: string): Promise<void> {
const blob = await this.ps('ConvertTo-SecureString -String $INPUT -AsPlainText -Force | ConvertFrom-SecureString', secret)
+1 -63
View File
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import { Collaboration, connectionsSuffix, opaqueConnectionId, presenceChannel, presencePeople, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
@@ -164,65 +164,3 @@ test('tags: vocabulary search, create, record tags and apply call the qbit opera
assert.deepEqual(calls[1]!.input.json, { table: 'person', label: 'New one', color: 'teal' })
assert.deepEqual(calls[3]!.input, { path: { table: 'person', pk: '3' }, json: { add: [9] } })
})
test('two connections of one user: one person with connections 2, and per connection each mode, field and start', async () => {
const leases = [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-mine'), sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-other'), sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
const { calls, transport } = fake((id, input) => {
if (id === 'deleteQbitCollaborationPresence') return { success: true }
const per = (input.query as Record<string, string | undefined> | undefined)?.perConnection === 'true'
return { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 2 }], leaseCount: 2, ...(per ? { leases } : {}) }
})
const collab = new Collaboration(withCollab, transport)
const merged = await collab.presenceSnapshot({ table: 'person', pk: '1' })
assert.deepEqual([merged.count, merged.connections, merged.people[0]!.connections, merged.people[0]!.leases], [1, 2, 2, undefined])
assert.equal(connectionsSuffix(merged.people[0]!), ' ×2')
assert.equal((calls.at(-1)!.input.query as Record<string, unknown>).perConnection, undefined, 'merged by default')
const handle = await collab.joinPresence({ table: 'person', pk: '1' }, { perConnection: true, connectionId: 'cli-mine', heartbeatMs: 10000 })
try {
const admin = handle.people[0]!
assert.equal(admin.connections, 2)
assert.deepEqual(admin.leases!.map((l) => [l.mode, l.fieldName, l.since?.toISOString(), l.self ?? false]), [
['VIEWING', null, '2026-11-01T12:00:00.000Z', true],
['EDITING', 'name', '2026-11-01T12:00:05.000Z', false],
])
assert.deepEqual(calls.filter((c) => c.id === 'getQbitCollaborationPresence').at(-1)!.input.query, { table: 'person', pk: '1', perConnection: 'true' })
} finally {
await handle.stop()
}
})
test('servers before collaboration 1.2.0 send no connections: each person is one connection', async () => {
const { transport } = fake(() => ({ table: 'person', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'bob', displayName: 'Bob' }] }))
const state = await new Collaboration(withCollab, transport).presenceSnapshot({ table: 'person' }, { perConnection: true })
assert.deepEqual([state.count, state.connections], [2, 2])
assert.deepEqual(state.people.map((p) => [p.connections, p.leases, connectionsSuffix(p)]), [[1, undefined, ''], [1, undefined, '']])
const fromEvent = presencePeople({ table: 'person', viewers: [{ userId: 'x', displayName: 'X', connections: 3 }], leaseCount: 3 }, () => new Date(0))
assert.deepEqual([fromEvent.connections, fromEvent.people[0]!.connections], [3, 3])
})
test('presence.changed with leases updates connections live', async () => {
const events: RealtimeMessage[] = [
{ event: 'hello', data: { connectionId: 'sse-1' } },
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }], leaseCount: 2,
leases: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c1', since: '2026-11-01T12:00:00Z' }, { userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c2', since: '2026-11-01T12:00:01Z' }] } },
]
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
let next = 0
while (!signal.aborted) {
await tick(2)
if (next < events.length) yield events[next++]!
}
}
const { transport } = fake((id, input) => (id === 'updateRealtimeSubscriptions' ? { active: (input.json as { add: string[] }).add } : { table: 'person', pk: '1', viewers: [] }), stream)
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
try {
await until('the event with two connections', () => handle.state.connections === 2)
assert.equal(handle.people[0]!.leases?.length, 2)
} finally {
await handle.stop()
}
})
-37
View File
@@ -1,37 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { DpapiFileStore } from '../session/keyring.js'
test('Windows DPAPI preserves JSON, shell syntax, Unicode and whitespace without plaintext storage', { skip: process.platform !== 'win32' }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-fixture-'))
const file = join(dir, 'secrets.dpapi.json')
const secret = ' ' + JSON.stringify({ token: 'obviously-fake-credential', syntax: '\"\'`$(){}\\', text: 'héllo 世界\nsecond line' }) + '\n '
try {
const store = new DpapiFileStore(file)
await store.set('fixture', secret)
assert.equal(readFileSync(file, 'utf8').includes('obviously-fake-credential'), false)
assert.equal(await new DpapiFileStore(file).get('fixture'), secret)
assert.equal(await store.delete('fixture'), true)
assert.equal(await store.get('fixture'), undefined)
} finally { rmSync(dir, { recursive: true, force: true }) }
})
test('Windows credential failure does not expose subprocess arguments or credential material', { skip: process.platform !== 'win32' }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-failure-'))
const file = join(dir, 'secrets.dpapi.json')
try {
writeFileSync(file, JSON.stringify({ fixture: 'obviously-fake-invalid-dpapi-value' }))
await assert.rejects(new DpapiFileStore(file).get('fixture'), error => {
assert.equal(String(error), 'Error: Windows credential protection failed; check PowerShell and your Windows profile')
return true
})
} finally { rmSync(dir, { recursive: true, force: true }) }
})
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "6.0.3"
"typescript": "5.8.3"
}
}
+7 -39
View File
@@ -15,16 +15,15 @@ says `binary with ui`. The npm package `@qrun-io/qqq-cli` does not carry it. Fro
```sh
npm ci && npm run build # once, at the repo root (the kernel and CLI are used built)
(cd packages/tui && bun install --frozen-lockfile) # once
qqq ui # the splash, then the only profile (or a picker); or: qqq --profile sample ui
qqq ui # the default profile; or: qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly (CLI grammar)
qqq ui --theme light | --ascii | --linear | --no-mouse | --no-splash | --check | --no-check
qqq ui --theme light | --ascii | --linear | --no-mouse | --check | --no-check
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
```
Options: `--profile|-p <name>`, `--server|-s <url>` (a profile named after the host is made), `--theme
<name>`, `--ascii` (ASCII glyphs), `--linear` (one region, no Apps pane), `--no-mouse` / `--mouse` (the
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)), `--no-splash` /
`--splash` (the start-up splash for this run; see [Starting](#starting)),
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)),
`--check` (the full terminal check, then exit), `--no-check` (skip the quick check at startup). Anything
after the options is a route in the CLI grammar.
@@ -32,36 +31,7 @@ Environment: `QQQ_UI_LINEAR=1`, `QQQ_UI_ASCII=1` (as the options); `QQQ_UI_SIGN_
OAUTH2 choice); `QQQ_UI_NO_BROWSER=1` (show the sign-in address without opening a browser);
`QQQ_PROFILE`, `QQQ_SERVER`, `QQQ_TOKEN` (a bearer token instead of the stored sign-in), `QQQ_CONFIG_DIR`,
`QQQ_STATE_DIR`, `QQQ_CACHE_DIR`, `QQQ_KEYRING=file` (keep sign-ins in a file instead of the OS keychain),
shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Starting
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.0.0 · UI API client 1.8.0`) and one status
line that follows the boot: reading profiles, connecting to the server, signing in, loading metadata (with
the table count). The large Query needs about 34 rows; smaller terminals (80×24) get the small Query beside
the wordmark, very small ones the text alone; `--ascii` draws it all in plain characters. Any key stops the
animation (not the boot). The splash stays at least 600 ms so it never just flashes, then Home opens as
before. `--no-splash`, `QQQ_UI_NO_SPLASH=1`, or F9 › Settings › **Splash on start: off** (`:splash off`,
saved as `ui.splash: off`) skip it.
Which server:
- `--profile`, `QQQ_PROFILE`, `--server` or `QQQ_SERVER` names one: it connects straight away. So does a
config with exactly one profile.
- Several profiles and none named: the **Servers picker** opens over the splash (the rows of the Servers
dialog, [Servers](#servers): name, short name, server, status, last used; who is signed in, the sign-in,
the description), with the last used profile selected. Enter connects; `n` adds a profile, `e` edits, typing
filters; the status column fills in as the servers answer (the picker never waits for them). Esc quits and
prints how to skip the picker (`--profile NAME` or `QQQ_PROFILE`).
- No profile at all (the first run): the add form opens directly; saving connects to the new profile. Esc
closes it; then `n` opens it again and Esc quits.
The sign-in runs as before (a MOCK or anonymous session, the OAuth2 browser or device flow with the address
or code on the splash, the username and password dialog). A failure stays on the splash with the reason:
Enter (or `r`) retries, `c` opens the picker to choose another profile (Esc there returns), `n` adds one,
Esc quits. Ctrl+O opens the picker too. With `--no-splash` the sign-in screen comes first, as in 1.0.0,
and the picker or the add form opens over it in the same cases.
shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Keys
@@ -611,8 +581,7 @@ Everything below is local to the machine and shared with the CLI; nothing is sen
1.0.0-rc.1 `note`, `lastUsedAt` and `user` recorded by the UI, and `ui.theme` for that profile only)
and `defaultProfile`. Fields are only ever added: a 1.0.0-rc.1 file loads unchanged.
Sign-ins themselves are in the OS keychain (or the file keyring), one per profile.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off;
`ui.splash`: `off` when `qqq ui` starts without the splash.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off.
- `ui.dates` (global, or in a profile's `ui`): date order (`YMD`, `MDY`, `DMY`), `clock` (`24h`, `12h`),
`weekStart`, `weekNumbers`, `pickerOnEnter`, set with `:dates`. Without them the clock and the first day
of the week follow the locale (`LC_ALL`, `LC_TIME`, `LANG`), else 24-hour and Monday; the order is ISO.
@@ -637,7 +606,7 @@ global choice. `--ascii` replaces box drawing and symbols. Colours come only fro
The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal.
F9 › Settings also holds the mouse on/off, the splash on start on/off, single-key shortcuts on/off (default: the server preference
F9 › Settings also holds the mouse on/off, single-key shortcuts on/off (default: the server preference
`keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check.
## Keys that your terminal may intercept
@@ -671,11 +640,10 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
```sh
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
# --server URL: an isolated config with two profiles, through the start-up picker
packages/tui/scripts/mouse-smoke.sh # the mouse in tmux: SGR 1006 clicks, drags and wheel written into the pane
packages/tui/scripts/smoke-widths.sh # fixed grid widths across pages (fake large-instance server)
packages/tui/scripts/smoke-sql.sh [server] # SQL Runner at 80x24 and 120x36 (default the enterprise server on 8078)
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog (the splash, its picker, failure and add form too) at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/perf-large-table.sh # PRF-001: 10,000 rows x 40 columns, timings against a 2 s budget
packages/tui/scripts/soak.sh [server] [minutes] # random keys for 10 min; fails on an exit or an error boundary
```
+2 -2
View File
@@ -12,7 +12,7 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "6.0.3",
"typescript": "5.8.3",
},
},
},
@@ -73,7 +73,7 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "6.0.3"
"typescript": "5.8.3"
}
}
-13
View File
@@ -65,22 +65,9 @@ shot() {
if [[ $bad == 0 ]]; then echo "ok $name"; else fails=$((fails + 1)); fi
}
cmd() { keys T:: "T:$1" Enter; }
# start_splash NAME CONFIG-JSON: a fresh TUI on its own config, no --server (the start-up splash, picker or add form)
start_splash() {
tmux kill-session -t "$s" 2>/dev/null || true
mkdir -p "$cfg/$1/config"
printf '%s\n' "$2" > "$cfg/$1/config/config.json"
tmux new-session -d -s "$s" -x 120 -y 36 "cd '$pkg' && QQQ_CONFIG_DIR='$cfg/$1/config' QQQ_STATE_DIR='$cfg/$1/state' QQQ_KEYRING=file QQQ_CACHE_DIR='$cfg/$1/cache' bun run src/main.tsx --no-check; sleep 30"
tmux set-option -t "$s" window-size manual > /dev/null
}
long="$(printf 'Long value %.0s' $(seq 1 30))"
shots=0
# the start-up splash: the picker (two profiles), a failed connection (retry / choose another), the add form (no profile)
start_splash picker "{\"profiles\":{\"sweep\":{\"server\":\"$server\",\"description\":\"the sweep server\"},\"other\":{\"server\":\"$server\"}}}"; shot 00a-splash-picker 'Connect to a server' 'Terminal client for QQQ'
start_splash failed '{"profiles":{"down":{"server":"http://127.0.0.1:9"}}}'; shot 00b-splash-failed 'Enter retries' 'Terminal client for QQQ'
start_splash add '{"profiles":{}}'; shot 00c-splash-add 'Add a server profile'
keys Escape; shot 00d-splash-no-profile 'n adds a server profile'
start; shot 01-home 'Quick actions' 'Recently viewed'
keys T:?; shot 02-help 'Keys'
start; keys T:/; shot 03-search 'Search'
+4 -13
View File
@@ -7,9 +7,7 @@
# packages/tui/scripts/smoke.sh [profile] a profile of your own config
# packages/tui/scripts/smoke.sh --server URL [--profile NAME] any server, in an isolated config
# (MOCK or anonymous sign-in; your
# config, keyring and cache untouched;
# two profiles, so it starts at the
# start-up picker and connects with Enter)
# config, keyring and cache untouched)
# OUT=dir WIDTH=160 HEIGHT=45
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -28,12 +26,11 @@ done
out="${OUT:-$(mktemp -d)}"
env_prefix=""
if [[ -n "$server" ]]; then
# an isolated config with two profiles: the start-up picker opens with this one (the last used) preselected
# an isolated config with one profile for the server, so the TUI signs in there instead of showing the picker
profile="${profile:-smoke}"
cfg="$(mktemp -d)"
mkdir -p "$cfg/config"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '{"profiles":{"%s":{"server":"%s","lastUsedAt":"%s"},"smoke-other":{"server":"%s","lastUsedAt":"2020-01-01T00:00:00Z"}},"defaultProfile":"smoke-other"}\n' "$profile" "${server%/}" "$now" "${server%/}" > "$cfg/config/config.json"
printf '{"profiles":{"%s":{"server":"%s"}},"defaultProfile":"%s"}\n' "$profile" "${server%/}" "$profile" > "$cfg/config/config.json"
env_prefix="QQQ_CONFIG_DIR='$cfg/config' QQQ_CACHE_DIR='$cfg/cache' QQQ_STATE_DIR='$cfg/state' QQQ_KEYRING=file"
fi
profile="${profile:-sample}"
@@ -57,13 +54,7 @@ expect() { # name text...
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
}
echo "smoke: profile $profile${server:+ at $server (isolated config)}; captures in $out"
if [[ -n "$server" ]]; then
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check; sleep 5"
expect 00-picker 'Connect to a server' 'Enter connects' 'Esc quits' 'Terminal client for QQQ' "$profile"
keys Enter
else
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
fi
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
expect 01-home 'Apps' 'UI API' 'Recently viewed' 'Quick actions'
keys T:: 'T:table query person' Enter
expect 02-grid 'records ·' 'First Name'
+1 -14
View File
@@ -143,19 +143,6 @@ export function App({ store }: { store: AppStore }) {
const contentWidth = w - sidebarWidth - assistantWidth
const screen = store.screen
const top = store.overlays[store.overlays.length - 1]
const overlays = store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)
if (screen?.kind === 'splash') {
// the start-up splash has the whole terminal: no title bar, status line or key bar yet
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
<ErrorBoundary resetKey={screen.id} width={w} label="Splash">{screen.render(w, h, !top)}</ErrorBoundary>
{overlays}
<MouseCapture width={w} height={h} />
</box>
</MouseRoot>
)
}
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
@@ -174,7 +161,7 @@ export function App({ store }: { store: AppStore }) {
<StatusLine store={store} width={w} />
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
{overlays}
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
<MouseCapture width={w} height={h} />
</box>
-104
View File
@@ -1,104 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What `qqq ui` does first, decided from the options, the environment and config.json before
* anything is drawn: connect to one profile, ask which profile (several saved, none named), or add
* the first one (none saved). Also the Splash setting (`ui.splash`: on unless off) and its timing.
*/
import { readConfig, resolveProfile, writeConfig, type Config } from '@qrun-io/qqq-core'
import type { Target } from './store'
/** The splash stays up at least this long (ms) so it never just flashes. */
export const SPLASH_MIN_MS = 600
export type BootPlan =
/** A profile was named (--profile, QQQ_PROFILE, --server, QQQ_SERVER) or only one exists. */
| { kind: 'connect'; target: Target }
/** Several profiles and none named: the Servers picker, `select` preselected (the last used). */
| { kind: 'pick'; select?: string; note?: string }
/** No profile at all: the add form. */
| { kind: 'add'; note?: string }
/** Whether a profile was named for this run (an option or the environment). */
export function namedProfile(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(args.profile || args.server || env.QQQ_PROFILE || env.QQQ_SERVER)
}
/** The most recently used profile (config lastUsedAt), else the default, else the first. */
export function lastUsedProfile(config: Config): string | undefined {
const names = Object.keys(config.profiles)
let best: string | undefined
let bestAt = -Infinity
for (const n of names) {
const at = Date.parse(config.profiles[n]!.lastUsedAt ?? '')
if (!Number.isNaN(at) && at > bestAt) {
best = n
bestAt = at
}
}
return best ?? (config.defaultProfile && config.profiles[config.defaultProfile] ? config.defaultProfile : names[0])
}
/** Decides the first step of a run (pure: config and environment are passed in by tests). */
export function planBoot(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env, config: Config = readConfig(env)): BootPlan {
const names = Object.keys(config.profiles)
if (!namedProfile(args, env) && names.length === 1) return { kind: 'connect', target: { name: names[0]!, server: config.profiles[names[0]!]!.server } }
if (namedProfile(args, env)) {
try {
const r = resolveProfile({ profile: args.profile, server: args.server }, env, config)
return { kind: 'connect', target: { name: r.name, server: r.server } }
} catch (e) {
// a name that does not exist: choose from what there is (or add the first)
const note = e instanceof Error ? e.message : String(e)
return names.length ? { kind: 'pick', select: lastUsedProfile(config), note } : { kind: 'add', note }
}
}
if (!names.length) return { kind: 'add' }
return { kind: 'pick', select: lastUsedProfile(config) }
}
/** The Splash setting: on unless `ui.splash` is off (or false). */
export function readSplash(env: NodeJS.ProcessEnv = process.env): boolean {
try {
const v = (readConfig(env).ui as { splash?: unknown } | undefined)?.splash
return !(v === false || v === 'off')
} catch {
return true
}
}
export function writeSplash(on: boolean, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
config.ui = { ...config.ui, splash: on ? 'on' : 'off' } as never
writeConfig(config, env)
}
/** Whether this run shows the splash: --no-splash (or QQQ_UI_NO_SPLASH=1) and the setting turn it off. */
export function splashEnabled(args: { splash?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
if (args.splash !== undefined) return args.splash
if (env.QQQ_UI_NO_SPLASH === '1') return false
return readSplash(env)
}
/** How long to wait before leaving the splash, given when it was shown. */
export function splashWait(shownAt: number, now: number, minMs = SPLASH_MIN_MS): number {
return Math.max(0, shownAt + minMs - now)
}
/** The one line printed when the picker is closed without choosing. */
export const PICK_HINT = 'qqq ui: no profile chosen; --profile NAME (or QQQ_PROFILE) skips the picker'
/** Settings > Splash on start and `:splash on|off`: saved for the next start. */
export function setSplashSetting(store: { flash: (text: string) => void; error: (e: unknown) => void }, on: boolean): void {
try {
writeSplash(on)
} catch (e) {
store.error(e)
return
}
store.flash(on ? 'splash on (saved): the next qqq ui starts on the splash' : 'splash off (saved): the next qqq ui starts without it (--splash shows it once)')
}
+10 -42
View File
@@ -5,15 +5,11 @@
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
import { probeServer, readConfig, readTheme, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { PICK_HINT, SPLASH_MIN_MS, planBoot, splashEnabled, type BootPlan } from './boot-plan'
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { installCommands } from './commands'
import { readMouse } from './mouse/settings'
import { readFKeyBar } from './keys/shift-bank'
import { ProfileFormOverlay, ServersOverlay } from './overlays/servers'
import { ServersModel } from './servers'
import { SignInScreen, type SignInDeps } from './screens/signin'
import { SplashScreen } from './screens/splash'
import { Sidebar } from './screens/sidebar'
import { AppStore, setSecretStoreFactory, type Target } from './store'
import { DEFAULT_THEME, THEMES } from './theme'
@@ -26,8 +22,6 @@ export interface CliArgs {
linear: boolean
/** --mouse / --no-mouse for this run; unset: the saved Mouse setting (on by default). */
mouse?: boolean
/** --splash / --no-splash for this run; unset: the Splash setting (ui.splash, on by default). */
splash?: boolean
check: 'startup' | 'full' | 'off'
route: string[]
}
@@ -47,8 +41,6 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
else if (a === '--linear') out.linear = true
else if (a === '--mouse') out.mouse = true
else if (a === '--no-mouse') out.mouse = false
else if (a === '--splash') out.splash = true
else if (a === '--no-splash') out.splash = false
else if (a === '--check') out.check = 'full'
else if (a === '--no-check') out.check = 'off'
else out.route.push(a)
@@ -63,23 +55,19 @@ export interface BootOptions {
secrets?: () => SecretStore
fetch?: typeof fetch
timers?: boolean
/** Shows the splash; unset: --no-splash, QQQ_UI_NO_SPLASH=1 and the Splash setting decide. */
splash?: boolean
/** The splash's minimum display time; unset: 600 ms with timers (a real run), else none. */
splashMinMs?: number
/** Prints a line once the terminal is restored (the picker closed without a choice). */
hint?: (text: string) => void
}
/**
* Creates the store and starts the boot: the splash (unless switched off), then the profile named
* or the only one; with several the Servers picker, with none the add form (boot-plan.ts).
*/
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
export function boot(options: BootOptions): AppStore {
const { args } = options
const plan: BootPlan = planBoot(args)
const target: Target | undefined = plan.kind === 'connect' ? plan.target : undefined
let note = plan.kind === 'connect' ? undefined : plan.note
let target: Target | undefined
let note: string | undefined
try {
const r = resolveProfile({ profile: args.profile, server: args.server })
target = { name: r.name, server: r.server }
} catch (e) {
note = e instanceof Error ? e.message : String(e)
}
if (args.theme) {
if (THEMES[args.theme]) writeTheme(args.theme)
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
@@ -105,29 +93,9 @@ export function boot(options: BootOptions): AppStore {
},
}
store.signInDeps = deps
const exit = (hint: string) => {
store.quit()
if (hint) options.hint?.(hint)
}
if (options.splash ?? splashEnabled(args)) {
const splash = new SplashScreen(store, { plan, deps, exit, minMs: options.splashMinMs ?? (options.timers ? SPLASH_MIN_MS : 0), animate: Boolean(options.timers) })
store.push(splash)
splash.start()
return store
}
// --no-splash: the sign-in screen, with the same first step
const screen = new SignInScreen(store, deps, target, note)
store.push(screen)
if (target) screen.choose(target)
else {
const model = new ServersModel({ secrets: secretsFn, probe: (server) => probeServer(server, { fetch: options.fetch }), onChange: () => store.bump() })
if (plan.kind === 'pick') store.open(new ServersOverlay(store, (row) => screen.choose({ name: row.name, server: row.server }), model, { title: 'Connect to a server', enterLabel: 'Enter connects', escLabel: 'Esc quits', select: plan.select, onEscape: () => exit(PICK_HINT) }))
else store.open(new ProfileFormOverlay(store, model, undefined, (name) => {
const p = readConfig().profiles[name]
screen.build()
if (p) screen.choose({ name, server: p.server })
}))
}
return store
}
+2 -2
View File
@@ -210,10 +210,10 @@ export class RecordCollaboration {
}
/** Who is on the record (the actions entry): everyone the server reports, this user marked "(you)". */
export function viewingText(everyone: Array<{ name: string; self?: boolean; connections?: number }> | undefined): string {
export function viewingText(everyone: Array<{ name: string; self?: boolean }> | undefined): string {
if (!everyone) return 'Viewing now: …'
if (!everyone.length) return 'Viewing now: no one else'
const names = everyone.map((p) => `${p.name}${p.connections && p.connections > 1 ? ` ×${p.connections}` : ''}${p.self ? ' (you)' : ''}`)
const names = everyone.map((p) => (p.self ? `${p.name} (you)` : p.name))
const others = everyone.filter((p) => !p.self).length
return `Viewing now: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${others ? ` · ${others} other${others === 1 ? '' : 's'}` : ''}`
}
+1 -5
View File
@@ -11,7 +11,6 @@
import { createProgram, runCli } from '@qrun-io/qqq-cli/program'
import { setMouseSetting } from './mouse/settings'
import { readSplash, setSplashSetting } from './boot-plan'
import { THEMES } from './theme'
import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
@@ -55,7 +54,7 @@ const NOUNS: Record<string, string[]> = {
apps: [], whoami: [], search: [], 'help-search': [],
}
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
/** One line on each TUI command, for the `:` line's completion popup. */
export const TUI_COMMAND_HELP: Record<string, string> = {
@@ -358,7 +357,6 @@ export class Commands implements CommandsLike {
case 'linear': s.settings.linear = rest[0] !== 'off'; s.bump(); return s.flash(`linear layout ${s.settings.linear ? 'on' : 'off'}`)
case 'shortcuts': return this.setShortcuts(rest[0] !== 'off')
case 'mouse': return setMouseSetting(s, rest[0] === undefined ? !s.settings.mouse : rest[0] !== 'off')
case 'splash': return setSplashSetting(s, rest[0] === undefined ? !readSplash() : rest[0] !== 'off')
case 'idle': {
const m = Number(rest[0])
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
@@ -766,8 +764,6 @@ export class Commands implements CommandsLike {
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
out.push({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
const splash = readSplash()
out.push({ id: 'settings:splash', section: 'Settings', label: `Splash on start: ${splash ? 'on' : 'off'}`, command: `splash ${splash ? 'off' : 'on'}`, detail: 'Query and the boot steps while qqq ui starts', run: () => setSplashSetting(s, !splash) })
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
let idle = 0
+1 -1
View File
@@ -30,7 +30,7 @@ function sameAction(command: string | undefined): string | undefined {
* `:` commands the TUI does itself (theme, lock, the Servers dialog …): logged as `:…`, re-run on
* the `:` line only. Everything else on the `:` line is a CLI command (`qqq …`).
*/
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
let last: { action?: string; at: number; words?: string[] } = { at: 0 }
+1 -7
View File
@@ -77,14 +77,8 @@ renderer.on('destroy', () => {
setTimeout(() => process.exit(0), 10)
})
// a line for the normal screen once the alternate screen is gone (the picker closed without a choice)
let exitHint: string | undefined
process.on('exit', () => {
if (exitHint) process.stderr.write(`${exitHint}\n`)
})
// QQQ_UI_NO_QUIT=1 (the soak test): quitting from a menu is ignored, so only a crash ends the program
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true, hint: (text) => { exitHint = text } })
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
process.on('unhandledRejection', (reason) => {
if (destroyed && isShutdownNoise([reason])) return
store.error(reason)
+7 -26
View File
@@ -33,21 +33,6 @@ function hint(tokens: KeyToken[], width: number): string {
/** Command keys while the filter is empty; any other character starts the filter (/ starts it too). */
const COMMAND_KEYS = new Set(['n', 'e', 'd', '*', 'r', '/'])
/** The dialog as the start-up profile picker (over the splash): its title, Enter and Esc, the preselected row. */
export interface ServersOverlayOptions {
title?: string
/** The Enter hint (`Enter connects`). */
enterLabel?: string
/** The Esc hint (`Esc quits`). */
escLabel?: string
/** Esc with an empty filter: instead of closing. */
onEscape?: () => void
/** The profile the cursor starts on (else the one in use). */
select?: string
/** The frame's first row for a screen height (below the splash's header), and only as tall as its rows need. */
top?: (screenHeight: number) => number
}
export class ServersOverlay implements Overlay {
readonly id = overlayId('servers')
readonly filter = new LineEditor('')
@@ -60,9 +45,9 @@ export class ServersOverlay implements Overlay {
/** `/` was pressed: every character goes to the filter. */
private filtering = false
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel, readonly options: ServersOverlayOptions = {}) {
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel) {
this.model = model ?? new ServersModel({ current: store.target?.name, secrets: store.signInDeps?.secrets, probe: (server) => probeServer(server, { fetch: store.signInDeps?.fetch }), onChange: () => store.bump() })
const at = options.select !== undefined && this.model.rows.some((r) => r.name === options.select) ? this.model.rows.findIndex((r) => r.name === options.select) : this.model.rows.findIndex((r) => r.isCurrent)
const at = this.model.rows.findIndex((r) => r.isCurrent)
this.index = Math.max(0, at)
void this.model.probeAll()
void this.model.readSignIns()
@@ -96,8 +81,7 @@ export class ServersOverlay implements Overlay {
this.filter.set('')
this.filtering = false
this.index = 0
} else if (this.options.onEscape) this.options.onEscape()
else this.store.closeOverlay(this)
} else this.store.closeOverlay(this)
} else if (key.name === 'down') this.index = Math.min(Math.max(0, list.length - 1), this.index + 1)
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
else if (key.name === 'pagedown') this.index = Math.min(Math.max(0, list.length - 1), this.index + 5)
@@ -168,9 +152,7 @@ export class ServersOverlay implements Overlay {
const t = theme()
const g = glyphs()
const width = Math.max(30, Math.min(sw - 2, 120))
const top = this.options.top ? this.options.top(sh) : 1
// under the splash header: as tall as the profiles need, else up to the screen's foot
const height = this.options.top ? Math.max(10, Math.min(sh - top - 1, 30, this.model.rows.length * 2 + 6)) : Math.max(10, Math.min(sh - 2, 30))
const height = Math.max(10, Math.min(sh - 2, 30))
const inner = width - 2
const list = this.list()
if (this.index >= list.length) this.index = Math.max(0, list.length - 1)
@@ -204,13 +186,12 @@ export class ServersOverlay implements Overlay {
const more = list.length > visibleRows ? ` ${this.scroll > 0 ? g.up : ' '}${this.scroll + visibleRows < list.length ? g.down : ' '}` : ''
const header = ` ${pad('Name', nameW)}${pad('Short', shortW)}${pad('Server', serverW)}${pad('Status', statusW)}${pad('Last used', lastW)}`
const commands = !this.filter.value && !this.filtering
const enter = this.options.enterLabel ?? 'Enter switches'
const keys = commands
? [{ text: enter }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: this.options.escLabel ?? 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: enter }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
? [{ text: 'Enter switches' }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: 'Enter switches' }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
const legend = `* default ${g.mark} in use`
return (
<Frame title={this.options.title ?? 'Servers (Ctrl+O)'} width={width} height={height} screenWidth={sw} screenHeight={sh} top={top}>
<Frame title="Servers (Ctrl+O)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} focused={!commands || undefined} placeholder="type to filter by name, short name, description or server · / then any letter" /></box>
<Line text={pad(header, inner - more.length) + more} width={inner} fg={t.acc} />
{lines}
+2 -25
View File
@@ -20,24 +20,6 @@ export interface PresencePerson {
/** VIEWING or EDITING (the server's mode), and the field being edited when it says. */
mode?: string
fieldName?: string | null
/** Live connections (tabs, terminals) of this person; absent or 1 before collaboration 1.2.0. */
connections?: number
/** Each connection's mode, field and start when the server sends them (collaboration 1.2.0). */
terminals?: PresenceTerminal[]
}
/** One connection of a person: what that tab or terminal does. */
export interface PresenceTerminal {
mode?: string
fieldName?: string | null
since?: string
/** This terminal. */
self?: boolean
}
/** ` ×2` after a person with more than one connection. */
export function timesText(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
export interface Presence {
@@ -82,7 +64,7 @@ export function usePresence(scope: string | undefined): Presence {
/** Title-bar text for presence, empty when nobody else is there. */
export function presenceText(p: Presence): string {
if (!p.count) return ''
const names = p.people.slice(0, 3).map((x) => `${x.name}${timesText(x)}`).join(', ')
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
}
@@ -112,13 +94,8 @@ export function collaborationPresence(registry: ExtensionRegistry, client: TuiCl
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
current = entry
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
perConnection: true,
onChange: (state) => {
const everyone = state.people.map((p) => ({
name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName,
...(p.connections && p.connections > 1 ? { connections: p.connections } : {}),
...(p.leases?.length ? { terminals: p.leases.map((l) => ({ mode: l.mode, fieldName: l.fieldName, since: l.since?.toISOString(), ...(l.self ? { self: true } : {}) })) } : {}),
}))
const everyone = state.people.map((p) => ({ name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName }))
const people = everyone.filter((p) => !p.self).map(({ self: _self, ...person }) => person)
entry.value = { count: people.length, people, everyone }
notify()
+10 -25
View File
@@ -51,8 +51,6 @@ interface Viewer {
userId: string
displayName: string
mode?: string
/** Live connections (tabs, terminals) of this user on the table; absent before collaboration 1.2.0. */
connections?: number
}
/** Sessions that count as online: active in the last 10 minutes. */
@@ -66,13 +64,11 @@ function userLabel(userId: string): string {
* Who is online: signed-in sessions from the session store's table when the server has one and it
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
* (the saver keeps its lease, so the signed-in viewer is always one of them). Collaboration 1.2.0
* counts connections, so two terminals of one user are "2 online · you ×2"; older servers count each
* person once. Never "nobody around" while the viewer is signed in.
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
* while the viewer is signed in.
*/
export function presenceSource(ctx: SaverContext): TileSource {
// keyed by user and table: a user's terminals on different tables add up
const seen = new Map<string, { userId: string; name: string; table: string; mode?: string; connections?: number; self: boolean; at: number }>()
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
const skip = new Set<string>()
let cursor = 0
let sessionsGone = false
@@ -134,9 +130,10 @@ export function presenceSource(ctx: SaverContext): TileSource {
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
for (const v of snap.viewers ?? []) {
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : undefined
if (!you) names.set(v.userId, v.displayName || v.userId)
seen.set(`${v.userId}\u0000${table}`, { userId: v.userId, name: v.displayName || v.userId, table, mode: v.mode, connections, self: you, at: t })
// this terminal is you (it keeps its lease while the saver shows): not a second person
if (you) continue
names.set(v.userId, v.displayName || v.userId)
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
}
} catch (error) {
const status = (error as { status?: number }).status
@@ -149,21 +146,9 @@ export function presenceSource(ctx: SaverContext): TileSource {
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const t = now()
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
// one entry per person, their connections summed over the tables they are on; a server
// before collaboration 1.2.0 sends no connections, and then each person counts once
const people = new Map<string, { name: string; table: string; mode?: string; connections: number; counted: boolean; self: boolean }>()
for (const v of seen.values()) {
const p = people.get(v.userId)
if (!p) people.set(v.userId, { name: v.name, table: v.table, mode: v.mode, connections: v.connections ?? 1, counted: v.connections !== undefined, self: v.self })
else if (p.counted && v.connections !== undefined) p.connections += v.connections
else p.connections = 1, p.counted = false
}
const others = [...people.values()].filter((p) => !p.self).sort((a, b) => a.name.localeCompare(b.name))
// this terminal is you (it keeps its lease while the saver shows); your other terminals count too
const yours = signedIn ? Math.max(1, [...people.values()].filter((p) => p.self).reduce((n, p) => n + p.connections, 0)) : 0
const times = (n: number) => (n > 1 ? ` ×${n}` : '')
const total = others.reduce((n, p) => n + p.connections, 0) + yours
const parts = [...(signedIn ? [`you${times(yours)}`] : []), ...others.map((p) => `${p.name}${times(p.connections)}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
const total = others.length + (signedIn ? 1 : 0)
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
},
+5 -16
View File
@@ -12,7 +12,7 @@
import type { TagEntry } from '@qrun-io/qqq-core'
import type { CollabSummary } from '../collab'
import { timesText, type PresencePerson } from '../presence'
import type { PresencePerson } from '../presence'
import { ago } from '../servers'
import { KEY_SEP, cellWidth, pad, trunc } from '../util/text'
@@ -77,7 +77,7 @@ export function tagsText(tags: TagEntry[] | undefined): string {
}
/** What a person is doing on the record: "editing email", "editing", "viewing". */
export function personState(p: Pick<PresencePerson, 'mode' | 'fieldName'>): string {
export function personState(p: PresencePerson): string {
return /edit/i.test(p.mode ?? '') ? `editing${p.fieldName ? ` ${p.fieldName}` : ''}` : 'viewing'
}
@@ -92,11 +92,11 @@ function viewingParts(state: CollabState): StripPart[] {
const others = othersOf(state)
// others (with what they do) take the place of "(you)": the strip is about who else is here
if (others.length) {
const named = others.slice(0, 2).map((p) => `${p.name}${timesText(p)} (${personState(p)})`).join(', ')
const named = others.slice(0, 2).map((p) => `${p.name} (${personState(p)})`).join(', ')
return [{ text: 'viewing: ' }, { text: named + (others.length > 2 ? ` +${others.length - 2}` : ''), em: true }]
}
const self = everyone.find((p) => p.self)
return [{ text: 'viewing: ' }, { text: self ? `${self.name}${timesText(self)}` : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
return [{ text: 'viewing: ' }, { text: self ? self.name : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
}
const count = (n: number | undefined, one: string, many = `${one}s`): StripPart[] => [{ text: n === undefined ? '…' : String(n), em: true }, { text: ` ${n === 1 ? one : many}` }]
@@ -193,21 +193,10 @@ export function panelRows(state: CollabState, width: number, height: number, now
else if (!everyone.length) add(' no one else', 'dim', 'viewing')
// others first, with what they are doing; this user last
const people: Array<PresencePerson & { self?: boolean }> = [...othersOf(state), ...(everyone ?? []).filter((x) => x.self)]
for (const p of people) {
add(` ${p.name}${timesText(p)}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
// with more than one terminal, what each does (this one first)
const terminals = p.terminals && p.terminals.length > 1 ? [...p.terminals].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self))) : []
terminals.forEach((x, i) => add(` ${x.self ? 'this terminal' : `#${i + 1}`} · ${personState(x)}${x.since ? ` · ${hhmm(x.since)}` : ''}`, 'dim', 'viewing'))
}
for (const p of people) add(` ${p.name}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
return rows.slice(0, Math.max(0, height))
}
/** A lease start as local "17:50". */
function hhmm(iso: string): string {
const at = new Date(iso)
return Number.isNaN(at.getTime()) ? iso : at.toTimeString().slice(0, 5)
}
/** A due date as "3 Nov" (and the year when it is not this one). */
function dueText(iso: string): string {
const at = new Date(iso)
+4 -14
View File
@@ -12,7 +12,7 @@ import { actionsOverlay } from '../overlays/actions'
import { TagOverlay, collaborationOf } from '../overlays/tags'
import { RecordCollaboration, uploadBlocked, viewingText, type CollabSummary } from '../collab'
import { CollabScreen, sharePreview, toggleFollow, uploadAttachment, type CollabView } from './collab'
import { presenceSource, timesText } from '../presence'
import { presenceSource } from '../presence'
import { canPrintTable, printScreen } from '../printing'
import { AuditScreen } from './audits'
import { DeveloperScreen } from './developer'
@@ -303,9 +303,7 @@ export class RecordScreen implements Screen {
/** Who else is on the record, in a dialog. */
showViewers(): void {
const viewers = this.viewers()
const self = presenceSource().get(this.presenceScope()).everyone?.find((p) => p.self)
const yours = self?.connections && self.connections > 1 ? `\n\nYou have it open in ${self.connections} terminals.` : ''
this.store.message((viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`) + yours, 'info', 'Presence')
this.store.message(viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`, 'info', 'Presence')
}
build(): void {
@@ -611,15 +609,7 @@ export class RecordScreen implements Screen {
const collab = collaborationOf(this.store)
if (!collab) return this.store.flash(this.store.extensions.missing('collaboration'), 'error')
if (this.primaryKey === undefined) return
const tableName = this.tableName
const primaryKey = this.primaryKey
this.store.client!.call('getRecordFeatures', { path: { tableName, primaryKey: String(primaryKey) } }).then((result) => {
if (this.tableName !== tableName || this.primaryKey !== primaryKey) return
const features = result.data as { features?: string[]; capabilities?: Record<string, boolean> }
if (!features?.features?.includes('TAGS')) return this.store.flash('Tags are not enabled for this table.', 'error')
if (features.capabilities?.tag !== true) return this.store.flash('Your role cannot tag this record.', 'error')
this.store.open(new TagOverlay(this.store, collab, tableName, primaryKey, (t) => { this.tags = t; this.build() }))
}).catch((error) => this.store.flash(errorText(error), 'error'))
this.store.open(new TagOverlay(this.store, collab, this.tableName, this.primaryKey, (t) => { this.tags = t; this.build() }))
}
/** Counts, follow state and the panel details of the strip and the panel (collaboration extension). */
@@ -654,7 +644,7 @@ export class RecordScreen implements Screen {
/** The names of the others viewing this record (presence; this client is not counted). */
viewers(): string[] {
return presenceSource().get(this.presenceScope()).people.map((p) => `${p.name}${timesText(p)}`)
return presenceSource().get(this.presenceScope()).people.map((p) => p.name)
}
/** Opens comments, attachments, the timeline or tasks. */
-7
View File
@@ -22,10 +22,6 @@ export interface SignInDeps {
/** Runs after the home screen is shown (the route given on the command line). */
afterConnect?: (store: AppStore) => void
timers?: boolean
/** Boot steps as they happen (the splash's status line): connecting, loading metadata. */
progress?: (text: string) => void
/** Awaited once connected, before Home replaces the screens (the splash's minimum display time). */
beforeHome?: () => Promise<void>
}
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
@@ -61,9 +57,7 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
store.status.auth = 'QQQ_TOKEN'
}
try {
deps.progress?.('loading metadata…')
await store.connect(client, { timers: deps.timers })
deps.progress?.(`loaded metadata (${Object.keys(store.meta?.tables ?? {}).length} tables)`)
} catch (e) {
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
throw e
@@ -75,7 +69,6 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
// config.json unreadable: not critical here
}
history.record(store, { kind: 'auth', summary: `Signed in to ${target.server}${store.status.user ? ` as ${store.status.user}` : ''} (${store.status.auth})` })
await deps.beforeHome?.()
while (store.routes.length) store.routes.pop()?.dispose?.()
store.push(new HomeScreen(store))
deps.afterConnect?.(store)
-413
View File
@@ -1,413 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The start-up splash of `qqq ui`: Query (the qrun.io mascot) blinking and wagging while the TUI
* boots, the `qqq` wordmark, the version, and one status line that follows the boot steps (reading
* profiles, connecting, signing in, loading metadata). It drives the boot (boot-plan.ts decides the
* first step): with several profiles and none named the Servers picker opens over it; with none the
* add form; a failure stays on the splash with Enter retrying and c choosing another profile.
* Any key stops the animation (not the boot); --no-splash and `ui.splash: off` skip the splash.
*/
import { useEffect, useReducer } from 'react'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
import { probeServer, readConfig } from '@qrun-io/qqq-core'
import { PICK_HINT, splashWait, type BootPlan } from '../boot-plan'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { ProfileFormOverlay, ServersOverlay } from '../overlays/servers'
import { blockText, blockWidth } from '../saver/font'
import { queryFrame, querySize } from '../saver/query-art'
import { ServersModel } from '../servers'
import type { AppStore, Target } from '../store'
import { isAscii, theme, type ThemeTokens } from '../theme'
import type { FKeyLabels, Screen } from '../types'
import { cellWidth, errorText, trunc } from '../util/text'
import { NeedsSignIn, SignInScreen, connect, type SignInDeps } from './signin'
export type SplashPhase = 'starting' | 'picking' | 'adding' | 'connecting' | 'failed' | 'done'
/** The colour a piece of the splash is drawn in (a theme token). */
export type SplashTone = 'art' | 'mark' | 'fg' | 'dim' | 'status' | 'err' | 'key'
export interface SplashSeg {
text: string
tone: SplashTone
}
export const SPLASH_TAGLINE = 'Terminal client for QQQ'
/** `qqq 1.0.0 · UI API client 1.8.0` */
export function versionLine(): string {
return `qqq ${CLI_VERSION} · UI API client ${UI_API_VERSION}`
}
/** Query's pose at a moment: a blink every 3 s; while the boot works, the tail wags (his wave). */
export function splashPose(now: number, busy: boolean): { blink: boolean; wag: boolean } {
return { blink: now % 3000 < 200, wag: busy && Math.floor(now / 300) % 2 === 1 }
}
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const SPINNER_ASCII = ['|', '/', '-', '\\']
export interface SplashLayoutInput {
ascii: boolean
pose: { blink: boolean; wag: boolean }
status: SplashSeg
/** Lines under the status (a sign-in address, a device code). */
extra: SplashSeg[]
/** The key hint line, or ''. */
keys: string
/** `top`: the header only (the picker is drawn below it), from `headerTop`, keeping `reserve` rows under it. */
anchor: 'center' | 'top'
reserve?: number
headerTop?: number
}
/** Rows of the large header (Query, gap, wordmark, gap, tagline, version) and of the compact one. */
const LARGE_HEADER = querySize('large').height + 9
const COMPACT_HEADER = querySize('small').height + 1
export type SplashLine = SplashSeg[]
/** Which composition fits with `rowsBelow` rows under it: the large Query over the wordmark, the small one beside it, or text. */
export function splashTier(width: number, height: number, rowsBelow: number): 'large' | 'compact' | 'text' {
// a free row to spare, so a header and the picker under it never meet the screen's foot
if (width >= 40 && height > LARGE_HEADER + rowsBelow) return 'large'
if (width >= 40 && height > COMPACT_HEADER + rowsBelow) return 'compact'
return 'text'
}
/**
* The splash as lines of coloured pieces, centred in `width` and clipped to `width` × `height`
* (pure: tests read it at 80×24 and in ASCII). Blank lines are empty arrays.
*/
export function splashLayout(width: number, height: number, input: SplashLayoutInput): SplashLine[] {
const below: SplashLine[] = [[{ ...input.status }], ...input.extra.map((s) => [{ ...s }]), ...(input.keys ? [[{ text: input.keys, tone: 'key' as const }]] : [])]
const header = input.anchor === 'top'
const rowsBelow = header ? input.reserve ?? 0 : below.length + 1
const tier = splashTier(width, height, rowsBelow)
const mark = blockText('qqq', input.ascii)
const lines: SplashLine[] = []
if (tier === 'large') {
for (const row of queryFrame('large', input.pose, input.ascii)) lines.push([{ text: row, tone: 'art' }])
lines.push([])
for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
lines.push([])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
} else if (tier === 'compact') {
// the small Query beside the wordmark, the tagline under the wordmark
const art = queryFrame('small', input.pose, input.ascii)
const right = Math.max(blockWidth('qqq'), SPLASH_TAGLINE.length)
// the wordmark and the tagline (a row apart) centred against Query's height
const from = Math.max(0, Math.floor((art.length - mark.length - 2) / 2))
const taglineRow = Math.min(art.length - 1, from + mark.length + 1)
art.forEach((row, i) => {
const inMark = i >= from && i < from + mark.length
const text = inMark ? mark[i - from]! : i === taglineRow ? SPLASH_TAGLINE : ''
lines.push([{ text: row, tone: 'art' }, { text: ' ', tone: 'fg' }, { text: text.padEnd(right), tone: inMark ? 'mark' : 'fg' }])
})
lines.push([{ text: versionLine(), tone: 'dim' }])
} else {
if (width >= blockWidth('qqq') && height >= 7 + rowsBelow) for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
else lines.push([{ text: 'qqq', tone: 'mark' }])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
}
if (!header) {
lines.push([])
lines.push(...below)
}
// the ASCII glyph set (no UTF-8 locale): the middle dot and the ellipsis too
if (input.ascii) for (const line of lines) for (const seg of line) seg.text = seg.text.replace(/·/g, '-').replace(/…/g, '...')
const shown = lines.slice(0, Math.max(0, height))
const top = header ? input.headerTop ?? 1 : Math.max(0, Math.floor((height - shown.length) / 2))
const out: SplashLine[] = Array.from({ length: Math.min(top, height) }, () => [])
for (const line of shown) {
if (out.length >= height) break
out.push(centerSegs(line, width))
}
return out
}
/** Centres a line of pieces in `width` cells, cutting the last pieces with an ellipsis when it is wider. */
export function centerSegs(line: SplashLine, width: number): SplashLine {
const total = line.reduce((w, s) => w + cellWidth(s.text), 0)
if (total <= width) {
const left = Math.floor((width - total) / 2)
return left > 0 ? [{ text: ' '.repeat(left), tone: 'fg' }, ...line] : line
}
const out: SplashLine = []
let room = width
for (const s of line) {
if (room <= 0) break
const w = cellWidth(s.text)
if (w <= room) {
out.push(s)
room -= w
} else {
out.push({ text: trunc(s.text, room), tone: s.tone })
room = 0
}
}
return out
}
/** The plain text of a laid-out splash (tests, the overflow sweep). */
export function splashText(lines: SplashLine[]): string {
return lines.map((l) => l.map((s) => s.text).join('')).join('\n')
}
function toneColor(t: ThemeTokens, tone: SplashTone): string {
return tone === 'art' ? t.acc : tone === 'mark' ? t.acc2 : tone === 'dim' || tone === 'key' ? t.dim : tone === 'err' ? t.err : tone === 'status' ? t.acc : t.fg
}
export interface SplashOptions {
plan: BootPlan
/** How to connect and sign in (the store's signInDeps). */
deps: SignInDeps
/** Ends the program and prints a line (the picker closed without a choice). */
exit: (hint: string) => void
/** The splash stays at least this long (0 in tests). */
minMs: number
/** Animate Query (off in tests: one still frame). */
animate: boolean
}
export class SplashScreen implements Screen {
readonly kind = 'splash'
readonly id = 'splash'
phase: SplashPhase = 'starting'
status = 'reading profiles…'
statusKind: 'info' | 'error' = 'info'
/** The profile being connected to (Enter retries it after a failure). */
target?: Target
/** Query moves until a key is pressed. */
animate: boolean
readonly shownAt = Date.now()
/** The sign-in itself (prompts, device code, browser address) is the sign-in screen's, unshown. */
readonly signIn: SignInScreen
private signingIn = false
/** The picker's profiles (its height follows them). */
private pickerModel?: ServersModel
constructor(readonly store: AppStore, readonly options: SplashOptions) {
this.animate = options.animate
this.signIn = new SignInScreen(store, options.deps)
}
title(): string {
return 'qqq'
}
fkeys(): FKeyLabels {
return {}
}
/** Starts the boot plan. */
start(): void {
const plan = this.options.plan
if (plan.kind === 'connect') this.connectTo(plan.target)
else if (plan.kind === 'pick') this.pick(plan.note, false)
else this.add(plan.note)
}
private say(text: string, kind: 'info' | 'error' = 'info'): void {
this.status = text
this.statusKind = kind
this.store.bump()
}
private model(): ServersModel {
const deps = this.options.deps
return new ServersModel({ secrets: deps.secrets, probe: (server) => probeServer(server, { fetch: deps.fetch }), onChange: () => this.store.bump() })
}
/** The Servers picker over the splash; Esc quits (or, after a failure, returns to it). */
pick(note?: string, fromFailure = this.phase === 'failed'): void {
const model = this.model()
if (!model.rows.length) return this.add(note)
this.pickerModel = model
const before = { phase: this.phase, status: this.status, kind: this.statusKind }
this.phase = 'picking'
this.say(note ?? 'choose a server to connect to', note ? 'error' : 'info')
const select = this.target?.name ?? (this.options.plan.kind === 'pick' ? this.options.plan.select : undefined)
const overlay: ServersOverlay = new ServersOverlay(this.store, (row) => this.connectTo({ name: row.name, server: row.server }), model, {
title: 'Connect to a server',
enterLabel: 'Enter connects',
escLabel: fromFailure ? 'Esc goes back' : 'Esc quits',
select,
top: (sh) => this.pickerPlace(sh).pickerTop,
onEscape: () => {
this.store.closeOverlay(overlay)
if (!fromFailure) return this.options.exit(PICK_HINT)
this.phase = before.phase
this.say(before.status, before.kind)
},
})
this.store.open(overlay)
}
/** The picker's rows: two per profile and six of frame, filter, header and hints (the Servers dialog's own sum). */
private pickerHeight(): number {
return Math.max(10, Math.min(30, (this.pickerModel?.rows.length ?? 2) * 2 + 6))
}
/**
* The header and the picker under it, centred together; a terminal too short for both gets the
* picker near the top, over the header.
*/
pickerPlace(screenHeight: number): { headerTop: number; pickerTop: number } {
const pickerHeight = this.pickerHeight()
const header = splashLayout(this.store.width, screenHeight, { ...this.layoutInput('top'), reserve: pickerHeight + 1, headerTop: 0 }).length
const total = header + 1 + pickerHeight
if (total > screenHeight - 1) return { headerTop: 1, pickerTop: 1 }
const top = Math.max(0, Math.floor((screenHeight - total) / 2))
return { headerTop: top, pickerTop: top + header + 1 }
}
/** What to lay out at a height: the centred splash, or the header above the picker. */
inputFor(height: number, now = Date.now()): SplashLayoutInput {
if (!(this.phase === 'picking' && this.store.overlays.length)) return this.layoutInput('center', now)
const place = this.pickerPlace(height)
return { ...this.layoutInput('top', now), reserve: this.pickerHeight() + 1, headerTop: place.headerTop }
}
/** The add form (no profile yet, or `n`); a saved profile is connected to at once. */
add(note?: string): void {
this.phase = 'adding'
this.say(note ?? 'no server profile yet: add one to connect', note ? 'error' : 'info')
this.store.open(new ProfileFormOverlay(this.store, this.model(), undefined, (name) => {
const p = readConfig().profiles[name]
if (p) this.connectTo({ name, server: p.server })
}))
}
/** Connects to a profile (signing in when there is no stored sign-in); Home replaces the splash. */
connectTo(target: Target): void {
if (this.phase === 'connecting') return
this.phase = 'connecting'
this.target = target
const where = target.server.replace(/^https?:\/\//, '')
this.say(`connecting to ${where}…`)
const deps: SignInDeps = {
...this.options.deps,
progress: (text) => this.say(text),
beforeHome: () => new Promise((resolve) => setTimeout(resolve, splashWait(this.shownAt, Date.now(), this.options.minMs))),
}
const run = async () => {
try {
await connect(this.store, target, deps)
} catch (e) {
if (!(e instanceof NeedsSignIn)) throw e
this.signingIn = true
this.signIn.status = ''
this.say(`signing in to ${where}…`)
await this.signIn.signIn(target)
this.signingIn = false
this.say(`connecting to ${where}…`)
await connect(this.store, target, deps)
}
}
run().then(() => {
this.phase = 'done'
}).catch((e) => {
this.phase = 'failed'
this.say(errorText(e), 'error')
}).finally(() => {
this.signingIn = false
this.signIn.device = undefined
this.signIn.browserUrl = undefined
this.store.bump()
})
}
/** Every key is the splash's (no F-keys, menu or palette before a server is connected). */
claimsKey(): boolean {
return true
}
onKey(key: Key): boolean {
this.animate = false
const ch = charOf(key).toLowerCase()
if (key.name === 'escape') {
if (this.phase === 'connecting') this.signIn.onKey(key)
else if (this.phase === 'failed' || this.phase === 'adding' || this.phase === 'starting') this.options.exit(this.phase === 'failed' ? `qqq ui: ${this.status}` : PICK_HINT)
} else if (key.name === 'f10' || (key.ctrl && key.name === 'q')) this.options.exit('')
else if (this.phase === 'failed' && (key.name === 'return' || ch === 'r') && this.target) this.connectTo(this.target)
else if (this.phase !== 'connecting' && (ch === 'c' || ch === 'p' || (key.ctrl && key.name === 'o'))) this.pick()
else if (this.phase !== 'connecting' && ch === 'n') this.add()
this.store.bump()
return true
}
/** The status line: the sign-in's own words while it runs (creating a session, waiting for the browser). */
private statusSeg(now: number): SplashSeg {
const text = this.signingIn && this.signIn.status ? this.signIn.status : this.status
if (this.statusKind === 'error') return { text, tone: 'err' }
const busy = this.phase === 'connecting' || this.phase === 'starting'
if (!busy) return { text, tone: 'status' }
const frames = isAscii() ? SPINNER_ASCII : SPINNER
const spin = this.animate ? frames[Math.floor(now / 100) % frames.length]! : isAscii() ? '*' : '·'
return { text: `${spin} ${text}`, tone: 'status' }
}
private extra(): SplashSeg[] {
const s = this.signIn
if (s.browserUrl) return [{ text: 'Sign in in your browser (opened); if it did not open, visit:', tone: 'fg' }, { text: s.browserUrl, tone: 'fg' }]
if (s.device) {
const left = Math.max(0, Math.round((s.device.expires - Date.now()) / 1000))
return [{ text: `Open ${s.device.url}`, tone: 'fg' }, { text: `and confirm the code ${s.device.code} (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`, tone: 'fg' }]
}
return []
}
private keys(): string {
if (this.store.overlays.length) return ''
if (this.phase === 'failed') return 'Enter retries · c chooses another profile · n adds one · Esc quits'
if (this.phase === 'adding') return 'n adds a server profile · Esc quits'
if (this.phase === 'connecting' && (this.signIn.browserUrl || this.signIn.device)) return 'Esc cancels the sign-in'
return ''
}
layoutInput(anchor: 'center' | 'top', now = Date.now()): SplashLayoutInput {
const busy = this.phase === 'connecting' || this.phase === 'starting'
return { ascii: isAscii(), pose: this.animate ? splashPose(now, busy) : { blink: false, wag: false }, status: this.statusSeg(now), extra: this.extra(), keys: this.keys(), anchor }
}
/** The text as drawn now (tests). */
text(width: number, height: number): string {
return splashText(splashLayout(width, height, this.inputFor(height)))
}
render(width: number, height: number) {
return <SplashView splash={this} width={width} height={height} />
}
}
/** Repaints itself at about 10 fps while Query moves (only this component). */
function SplashView({ splash, width, height }: { splash: SplashScreen; width: number; height: number }) {
const [, tick] = useReducer((n: number) => n + 1, 0)
const moving = splash.animate && splash.phase !== 'done'
useEffect(() => {
if (!moving) return
const timer = setInterval(tick, 100)
return () => clearInterval(timer)
}, [moving])
const t = theme()
const lines = splashLayout(width, height, splash.inputFor(height))
return (
<box flexDirection="column" overflow="hidden" width={width} height={height}>
{lines.map((line, i) => (
<text key={i} flexShrink={0} wrapMode="none" height={1}>
{line.length ? line.map((s, k) => <span key={k} fg={toneColor(t, s.tone)}>{s.text}</span>) : ' '}
</text>
))}
</box>
)
}
+1 -16
View File
@@ -13,7 +13,7 @@ import { TuiClient } from '../src/client'
import { RecordCollaboration } from '../src/collab'
import type { RecordScreen } from '../src/screens/record'
import { readViewSettings } from '@qrun-io/qqq-core'
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab, recordTagPolicy } from './fake-collab'
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab'
import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings'
import { fakeFetch } from './fake-server'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
@@ -240,18 +240,3 @@ test('record view without the collaboration extension: no strip, no panel, the c
expect(f).not.toContain('Collaboration')
expect((h.store.screen as RecordScreen).list.current?.key).toStartWith('f:')
})
for (const enabled of [false, true]) {
test(`tag editor respects server capability: enabled=${enabled}, permission denied`, async () => {
fakeExtensions.push(COLLABORATION)
recordTagPolicy.enabled = enabled
recordTagPolicy.allowed = false
h = await startTui(160, 40)
await command(h, 'table get person 1')
await waitFor(h, (x) => x.includes('Tags (t)'))
await key('t')
const text = enabled ? 'Your role cannot tag this record.' : 'Tags are not enabled for this table.'
await waitFor(h, (x) => x.includes(text))
expect(h.store.overlays.length).toBe(0)
})
}
-4
View File
@@ -15,7 +15,6 @@ const json = (data: unknown, status = 200) => new Response(JSON.stringify(data),
export const fakeExtensions: J[] = []
export const COLLABORATION = { name: 'collaboration', version: '1.1.0', pathPrefix: '/qbit/collaboration', description: 'Record collaboration' }
export const recordTagPolicy = { enabled: true, allowed: true }
export const QUICK_SEARCH = { name: 'quick-search', version: '1.1.0', pathPrefix: '/qbit/quick-search', description: 'Quick search' }
export interface CollabState {
@@ -38,14 +37,11 @@ export function resetCollab(): void {
collabState.uploads = []
collabState.shares = []
collabState.tags = []
recordTagPolicy.enabled = true
recordTagPolicy.allowed = true
}
resetCollab()
export async function collabRoute(path: string, method: string, body: unknown): Promise<Response | undefined> {
const on = (name: string) => fakeExtensions.some((e) => e.name === name)
if (/^\/table\/person\/\d+\/features$/.test(path)) return json({ features: recordTagPolicy.enabled ? ['TAGS'] : [], capabilities: { tag: recordTagPolicy.allowed } })
if (path === '/search' && method === 'POST' && on('quick-search')) {
const term = String((body as J | undefined)?.searchTerm ?? '').toLowerCase()
const hits = 'ada lovelace'.includes(term) ? [{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: '1', highlights: [{ text: 'Ada Lovelace', fieldLabel: 'Name' }] }] : []
-1
View File
@@ -243,7 +243,6 @@ test('collaboration: who is viewing (this user marked) and comment lines', () =>
expect(viewingText([{ name: 'Admin', self: true }])).toBe('Viewing now: Admin (you)')
expect(viewingText([{ name: 'Tester', self: true }, { name: 'Pat Smith' }])).toBe('Viewing now: Tester (you), Pat Smith · 1 other')
expect(viewingText(undefined)).toBe('Viewing now: …')
expect(viewingText([{ name: 'Admin', self: true, connections: 2 }, { name: 'Pat Smith', connections: 1 }])).toBe('Viewing now: Admin ×2 (you), Pat Smith · 1 other')
expect(commentLines({ authorDisplayName: 'Pat', body: 'hi @[Lee](user:7)', createdAt: undefined, replies: [{ authorDisplayName: 'Lee', body: 'ok' }] }, () => 'now')).toEqual(['Pat · now: hi @Lee', ' ↳ Lee · now: ok'])
})
+1 -2
View File
@@ -50,8 +50,7 @@ export interface Harness {
/** Renders the whole TUI against the fake server and waits for the home screen. */
export async function startTui(width = 120, height = 36, options: { metaData?: unknown; auth?: FakeAuth; secrets?: () => SecretStore; until?: 'home' | 'signin' } = {}): Promise<Harness> {
const log: FakeLog = { requests: [] }
// the sign-in tests drive the sign-in screen (--no-splash); the rest go through the splash
const store = boot({ args: parseArgs(['--profile', 'fake', ...(options.until === 'signin' ? ['--no-splash'] : [])]), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const setup = await testRender(<App store={store} />, { width, height })
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
@@ -1,60 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { expect, test } from 'bun:test'
import { ExtensionRegistry, opaqueConnectionId } from '@qrun-io/qqq-core'
import { collaborationPresence, presenceText } from '../src/presence'
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.2.0' }] })
/** A client stub answering presence calls; records the GET queries. */
function client(answer: (query: Record<string, string | undefined>, own: string | undefined) => unknown) {
const queries: Array<Record<string, string | undefined>> = []
let own: string | undefined
return {
queries,
call: async (id: string, input: { query?: Record<string, string | undefined>; json?: { connectionId?: string } }) => {
if (input.json?.connectionId) own = input.json.connectionId
if (id === 'getQbitCollaborationPresence') queries.push(input.query ?? {})
const data = id === 'getQbitCollaborationPresence' ? answer(input.query ?? {}, own) : id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '7', viewers: [] }
return { status: 200, headers: new Headers(), data, contentType: 'application/json' }
},
}
}
async function until(condition: () => boolean): Promise<void> {
for (let i = 0; i < 500 && !condition(); i++) await new Promise((r) => setTimeout(r, 2))
}
test('the record presence asks per connection: "Admin ×2", this terminal marked among the terminals', async () => {
const c = client((query, own) => ({
table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }, { userId: 'pat', displayName: 'Pat', mode: 'VIEWING', connections: 2 }], leaseCount: 4,
...(query.perConnection === 'true' ? { leases: [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', own ?? ''), since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'cffff', since: '2026-11-01T12:00:01Z' },
] } : {}),
}))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(c.queries[0]!.perConnection).toBe('true')
const admin = p.everyone!.find((x) => x.self)!
expect(admin.connections).toBe(2)
expect(admin.terminals!.map((t) => [t.mode, t.self ?? false])).toEqual([['VIEWING', true], ['EDITING', false]])
expect(presenceText(p)).toBe('1 other viewing: Pat ×2')
source.enter!(undefined)
})
test('a server without connections (collaboration 1.1.0) still shows everyone, once each', async () => {
const c = client(() => ({ table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'pat', displayName: 'Pat', mode: 'EDITING' }] }))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(p.everyone!.map((x) => [x.name, x.connections, x.terminals])).toEqual([['Admin', undefined, undefined], ['Pat', undefined, undefined]])
expect(presenceText(p)).toBe('1 other viewing: Pat')
source.enter!(undefined)
})
+1 -1
View File
@@ -231,7 +231,7 @@ test('Ctrl+P on a record prints its record sheet; F8 says Print where there is n
expect(String(run.values.headerServer)).toContain('fake.test')
expect(run.values.headerUser).toBe(h.store.status.user)
expect(String(run.values.headerPrintedAt)).toMatch(/\d/)
expect(String(run.values.footerLeft)).toMatch(/^qqq ui \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? · .*fake\.test/)
expect(String(run.values.footerLeft)).toMatch(/^qqq ui \d+\.\d+\.\d+ · .*fake\.test/)
})
test('Print what? → Screen prints the terminal as drawn; a record print carries the breadcrumb', async () => {
-26
View File
@@ -93,29 +93,3 @@ test('panel rows: tags, latest two comments, tasks with the next due, files, fol
expect(rows.filter((r) => r.key === 'C').length).toBe(5)
expect(panelRows(busy, PANEL_WIDTH - 1, 5)).toHaveLength(5)
})
test('two terminals of one user: "×2" on the strip and in the panel, each terminal with its mode and start', () => {
const since = '2026-11-01T12:00:05Z'
const local = new Date(since).toTimeString().slice(0, 5)
const twice: CollabState = {
...alone,
everyone: [
{ name: 'Admin', self: true, mode: 'EDITING', fieldName: 'name', connections: 2, terminals: [{ mode: 'EDITING', fieldName: 'name', since }, { mode: 'VIEWING', self: true }] },
{ name: 'Pat Smith', mode: 'VIEWING', connections: 3 },
],
}
expect(text(twice, 200)).toContain('viewing: Pat Smith ×3 (viewing)')
expect(text({ ...alone, everyone: [twice.everyone![0]!] }, 200).trimEnd()).toEndWith('viewing: Admin ×2 (you)')
const lines = panelRows(twice, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(lines.slice(lines.indexOf(' Viewing now'))).toEqual([
' Viewing now',
' Pat Smith ×3 · viewing',
' Admin ×2 (you) · editing name',
' this terminal · viewing',
` #2 · editing name · ${local}`,
])
// a server before collaboration 1.2.0 sends no connections: no "×", no terminal lines
const old = panelRows(busy, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(old.join('\n')).not.toContain('×')
expect(old.join('\n')).not.toContain('this terminal')
})
-16
View File
@@ -134,22 +134,6 @@ describe('sources', () => {
expect((await s.load()).value).toBe('1')
})
test('presence counts connections: two terminals of one user are 2 online; older servers count each person once', async () => {
const viewers: Record<string, Array<{ userId: string; displayName: string; mode: string; connections?: number }>> = {
person: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }, { userId: 'u:bob', displayName: 'Bob', mode: 'VIEWING', connections: 2 }],
order: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }],
}
const s = presenceSource(ctx({ data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
const v = await s.load()
expect(v.value).toBe('4')
expect(v.detail).toBe('4 online · you ×2, Bob ×2 · PERSON')
// only this terminal's own lease, no "connections" (collaboration 1.1.0): you, once
const old = presenceSource(ctx({ data: () => ({ viewers: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING' }] }) }))
const o = await old.load()
expect(o.value).toBe('1')
expect(o.detail).toBe('1 online · you')
})
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
const refuse = () => { throw new UiApiError(404, {}) }
// signed out: nothing to show once every table refuses
-274
View File
@@ -1,274 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The start-up splash and profile picker: the boot plan, the picker (preselect, Enter, Esc), the add form, timing, ASCII, failures. */
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { readConfig, writeConfig, type Config } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import { PICK_HINT, SPLASH_MIN_MS, lastUsedProfile, planBoot, readSplash, splashEnabled, splashWait, writeSplash } from '../src/boot-plan'
import { ProfileFormOverlay, ServersOverlay } from '../src/overlays/servers'
import { SplashScreen, splashLayout, splashText, versionLine, type SplashLayoutInput } from '../src/screens/splash'
import { fakeFetch, type FakeLog } from './fake-server'
import { command, isolate, press, sessionSecrets, waitFor, type Harness } from './helpers'
import { querySize } from '../src/saver/query-art'
import { setAscii } from '../src/theme'
import { cellWidth } from '../src/util/text'
let h: Harness | undefined
let exits: string[] = []
let quits = 0
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
exits = []
quits = 0
setAscii(false)
})
const hour = 3_600_000
function profiles(map: Record<string, { server: string; lastUsedAt?: string }>, defaultProfile?: string): void {
writeConfig({ profiles: map, ...(defaultProfile ? { defaultProfile } : {}) } as Config)
}
function two(): void {
profiles({
alpha: { server: 'http://alpha.test', lastUsedAt: new Date(Date.now() - 5 * hour).toISOString() },
beta: { server: 'http://beta.test', lastUsedAt: new Date(Date.now() - hour).toISOString() },
}, 'alpha')
}
async function start(argv: string[], options: { width?: number; height?: number; minMs?: number; fetch?: typeof fetch; log?: FakeLog } = {}): Promise<Harness> {
const log = options.log ?? { requests: [] }
const store = boot({ args: parseArgs(argv, {}), quit: () => { quits++ }, build: 'test', secrets: sessionSecrets, fetch: options.fetch ?? fakeFetch(log), splashMinMs: options.minMs, hint: (t) => exits.push(t) })
const setup = await testRender(<App store={store} />, { width: options.width ?? 120, height: options.height ?? 40 })
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
return harness
}
test('the boot plan: a named profile or the only one connects; several ask (last used first); none adds', () => {
const at = (n: number) => new Date(Date.now() - n * hour).toISOString()
const several: Config = { profiles: { alpha: { server: 'http://a.test', lastUsedAt: at(5) }, beta: { server: 'http://b.test', lastUsedAt: at(1) }, gamma: { server: 'http://c.test' } }, defaultProfile: 'alpha' } as Config
expect(planBoot({}, {}, several)).toEqual({ kind: 'pick', select: 'beta' })
expect(planBoot({ profile: 'gamma' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'gamma', server: 'http://c.test' } })
expect(planBoot({}, { QQQ_PROFILE: 'alpha' }, several)).toEqual({ kind: 'connect', target: { name: 'alpha', server: 'http://a.test' } })
expect(planBoot({ server: 'http://b.test/x' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'beta', server: 'http://b.test' } })
expect(planBoot({}, { QQQ_SERVER: 'http://new.test' }, several).kind).toBe('connect')
const one: Config = { profiles: { solo: { server: 'http://solo.test' } } } as Config
expect(planBoot({}, {}, one)).toEqual({ kind: 'connect', target: { name: 'solo', server: 'http://solo.test' } })
expect(planBoot({}, {}, { profiles: {} } as Config)).toEqual({ kind: 'add' })
// a name that does not exist: the picker (or the add form) with why
const typo = planBoot({ profile: 'nope' }, {}, several)
expect(typo.kind).toBe('pick')
expect(typo.kind === 'pick' && typo.note).toMatch(/No profile "nope"/)
expect(planBoot({ profile: 'nope' }, {}, { profiles: {} } as Config).kind).toBe('add')
// never used: the default, else the first
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } }, defaultProfile: 'b' } as Config)).toBe('b')
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } } } as Config)).toBe('a')
})
test('the splash switch: --no-splash, --splash, QQQ_UI_NO_SPLASH and the ui.splash setting', () => {
expect(parseArgs(['--no-splash']).splash).toBe(false)
expect(parseArgs(['--splash']).splash).toBe(true)
expect(parseArgs([]).splash).toBeUndefined()
expect(splashEnabled({ splash: false })).toBe(false)
expect(splashEnabled({}, { ...process.env, QQQ_UI_NO_SPLASH: '1' })).toBe(false)
expect(readSplash()).toBe(true)
writeSplash(false)
expect(readConfig().ui).toMatchObject({ splash: 'off' })
expect(splashEnabled({})).toBe(false)
expect(splashEnabled({ splash: true })).toBe(true)
writeSplash(true)
expect(splashEnabled({})).toBe(true)
})
test('the minimum display time: never under 600 ms, nothing more once it has been up that long', async () => {
expect(SPLASH_MIN_MS).toBe(600)
expect(splashWait(1000, 1100)).toBe(500)
expect(splashWait(1000, 1700)).toBe(0)
profiles({ fake: { server: 'http://fake.test' } })
const began = Date.now()
h = await start(['--profile', 'fake'], { minMs: 600 })
await waitFor(h, () => h!.store.screen?.kind === 'splash')
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(Date.now() - began).toBeGreaterThanOrEqual(600)
})
test('one profile: the splash shows Query, the wordmark, the version and the boot steps, then Home', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 400 })
const f = await waitFor(h, (x) => x.includes('Terminal client for QQQ'))
expect(h.store.screen?.kind).toBe('splash')
expect(f).toContain(versionLine())
expect(f).toMatch(/qqq \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? · UI API client \d+\.\d+\.\d+/)
expect(f).toContain('█')
// no title bar, status line or key bar under the splash
expect(f).not.toMatch(/10\s*Quit/)
const steps = h.store.screen as SplashScreen
await waitFor(h, () => /loaded metadata \(\d+ tables\)/.test(steps.status))
await waitFor(h, (x) => h!.store.screen?.kind === 'home' && x.includes('Apps'), 4000)
expect(h.store.overlays.length).toBe(0)
})
test('several profiles: the picker over the splash, the last used preselected; Enter connects to it', async () => {
two()
h = await start([], { width: 120, height: 40 })
const f = await waitFor(h, (x) => x.includes('Connect to a server'))
const picker = h.store.overlays.at(-1) as ServersOverlay
expect(picker).toBeInstanceOf(ServersOverlay)
expect(picker.selected()?.name).toBe('beta')
expect(f).toContain('Enter connects')
expect(f).toContain('Esc quits')
// the splash header stays visible above the picker
expect(f).toContain('Terminal client for QQQ')
// probes fill in on their own (the picker does not wait for them)
await waitFor(h, (x) => x.includes('UI API'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('beta')
})
test('several profiles: Esc on the picker quits with the --profile hint', async () => {
two()
h = await start([])
await waitFor(h, (x) => x.includes('Connect to a server'))
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
expect(exits).toEqual([PICK_HINT])
expect(PICK_HINT).toContain('--profile')
})
test('--profile skips the picker even with several profiles', async () => {
two()
h = await start(['--profile', 'alpha'])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('alpha')
})
test('no profiles: the add form opens straight away; saving connects to the new profile', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([])
await waitFor(h, (x) => x.includes('Add a server profile'))
const form = h.store.overlays.at(-1) as ProfileFormOverlay
expect(form).toBeInstanceOf(ProfileFormOverlay)
form.editors.name.set('local')
form.editors.server.set('http://fake.test')
await press(h, () => form.save(), 50)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target).toEqual({ name: 'local', server: 'http://fake.test' })
})
test('no profiles, add form closed: the splash says n adds one and Esc quits', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([], { width: 80, height: 24 })
await waitFor(h, (x) => x.includes('Add a server profile'))
const f = await press(h, () => h!.setup.mockInput.pressEscape())
expect(f).toContain('n adds a server profile')
await press(h, () => h!.setup.mockInput.pressKey('n'))
expect(h.store.overlays.at(-1)).toBeInstanceOf(ProfileFormOverlay)
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
})
test('--no-splash: the sign-in screen as before, then Home; with several profiles the picker opens over it', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start(['--no-splash'])
expect(h.store.routes.some((r) => r.kind === 'splash')).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
h.store.dispose()
h.setup.renderer.destroy()
two()
h = await start(['--no-splash'])
await waitFor(h, (x) => x.includes('Connect to a server'))
expect(h.store.screen?.kind).toBe('signin')
expect((h.store.overlays.at(-1) as ServersOverlay).selected()?.name).toBe('beta')
})
test('a failure stays on the splash with retry and choose-another; c opens the picker, Esc goes back to it', async () => {
profiles({ fake: { server: 'http://fake.test' }, other: { server: 'http://other.test' } })
let down = true
const ok = fakeFetch()
const flaky = (async (input: RequestInfo | URL, init?: RequestInit) => {
if (down) throw new Error('connection refused')
return ok(input, init)
}) as typeof fetch
h = await start(['--profile', 'fake'], { width: 80, height: 24, fetch: flaky })
let f = await waitFor(h, (x) => x.includes('Enter retries'))
expect(f).toContain('connection refused')
expect(f).toContain('c chooses another profile')
await press(h, () => h!.setup.mockInput.pressKey('c'))
f = await waitFor(h, (x) => x.includes('Connect to a server'))
expect(f).toContain('Esc goes back')
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(0)
f = await waitFor(h, (x) => x.includes('Enter retries'))
down = false
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
test('any key stops the animation, not the boot', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 300 })
const splash = h.store.screen as SplashScreen
splash.animate = true
await press(h, () => h!.setup.mockInput.pressKey('x'))
expect(splash.animate).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
function input(ascii: boolean, anchor: 'center' | 'top' = 'center'): SplashLayoutInput {
return { ascii, pose: { blink: false, wag: false }, status: { text: 'connecting to localhost:8078…', tone: 'status' }, extra: [], keys: '', anchor }
}
test('the layout fits 80×24 (small Query beside the wordmark) and 120×40 (large Query); every line clipped', () => {
for (const [w, hgt] of [[80, 24], [120, 40], [40, 12], [30, 8], [20, 5]] as const) {
for (const ascii of [false, true]) {
setAscii(ascii)
const lines = splashLayout(w, hgt, input(ascii))
expect(lines.length).toBeLessThanOrEqual(hgt)
for (const l of lines) expect(cellWidth(l.map((s) => s.text).join(''))).toBeLessThanOrEqual(w)
}
}
setAscii(false)
const small = splashText(splashLayout(80, 24, input(false)))
expect(small).toContain('Terminal client for QQQ')
expect(small).toContain('connecting to localhost:8078…')
const large = splashLayout(120, 40, input(false))
expect(large.filter((l) => l.some((s) => s.tone === 'art')).length).toBe(querySize('large').height)
const header = splashLayout(80, 24, input(false, 'top'))
expect(splashText(header)).not.toContain('connecting')
expect(header.length).toBeLessThanOrEqual(querySize('small').height + 2)
})
test('ASCII: no braille or block characters, the wordmark in #', () => {
setAscii(true)
for (const [w, hgt] of [[80, 24], [120, 40]] as const) {
const text = splashText(splashLayout(w, hgt, input(true)))
expect(text).not.toMatch(/[⠀-⣿█]/)
expect(text).toContain('####')
expect(text).toContain('Terminal client for QQQ')
expect(text).toMatch(/^[\x20-\x7e\n]*$/)
expect(text).toContain('qqq')
}
})
test(':splash off and on save the setting (Settings > Splash on start)', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
await command(h, 'splash off')
expect(readSplash()).toBe(false)
expect(h.store.flashText?.text).toContain('splash off')
await command(h, 'splash on')
expect(readSplash()).toBe(true)
})
@@ -31,10 +31,10 @@
},
{
"name": "collaboration",
"version": "1.2.0",
"document": ".worktrees/qbit-collab-sessions/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "feat/per-session-presence",
"commit": "0ae1e62"
"version": "1.1.0",
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "develop",
"commit": "03407ae"
},
{
"name": "data-analysis",
+2 -47
View File
@@ -73,7 +73,7 @@
},
{
"name": "collaboration",
"version": "1.2.0",
"version": "1.1.0",
"description": "Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence",
"pathPrefix": "/qbit/collaboration",
"operations": [
@@ -13972,15 +13972,6 @@
"schema": {
"type": "string"
}
},
{
"description": "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.",
"in": "query",
"name": "perConnection",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -41293,28 +41284,13 @@
"items": {
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
},
"type": "array",
"description": "Live viewers; always present, empty once the last viewer leaves.",
"nullable": false
},
"leaseCount": {
"description": "Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.",
"nullable": true,
"type": "integer"
},
"leases": {
"description": "One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.",
"items": {
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
},
"nullable": true,
"type": "array"
}
},
"type": "object"
},
"CollaborationPresenceViewerV1": {
"description": "One user merged across live tabs (EDITING takes precedence), or one live connection in leases.",
"description": "One user merged across live tabs; EDITING takes precedence.",
"properties": {
"displayName": {
"description": "Session display name.",
@@ -41339,27 +41315,6 @@
"description": "Stable user identity.",
"nullable": false,
"type": "string"
},
"connectionId": {
"description": "Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.",
"nullable": true,
"type": "string"
},
"connections": {
"description": "Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.",
"nullable": true,
"type": "integer"
},
"sessionId": {
"description": "Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.",
"nullable": true,
"type": "string"
},
"since": {
"description": "When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.",
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"type": "object"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.1-rc.1",
"version": "1.8.0",
"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",
@@ -47,7 +47,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"generate": "openapi-typescript openapi/qqq-v1.json --output src/schema.ts && node ../../scripts/fix-recursive-types.mjs src/schema.ts",
+1 -19
View File
@@ -8936,14 +8936,9 @@ export interface components {
pk?: string | null;
/** @description Parent table name. */
table?: string;
/** @description Live viewers; always present, empty once the last viewer leaves. */
viewers?: components["schemas"]["CollaborationPresenceViewerV1"][];
/** @description Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0. */
leaseCount?: number | null;
/** @description One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0. */
leases?: components["schemas"]["CollaborationPresenceViewerV1"][] | null;
};
/** @description One user merged across live tabs (EDITING takes precedence), or one live connection in leases. */
/** @description One user merged across live tabs; EDITING takes precedence. */
CollaborationPresenceViewerV1: {
/** @description Session display name. */
displayName?: string;
@@ -8956,17 +8951,6 @@ export interface components {
mode?: "VIEWING" | "EDITING";
/** @description Stable user identity. */
userId?: string;
/** @description Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0. */
connectionId?: string | null;
/** @description Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0. */
connections?: number | null;
/** @description Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0. */
sessionId?: string | null;
/**
* Format: date-time
* @description When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.
*/
since?: string | null;
};
/** @description Most recent visible targets, at most fifty. */
CollaborationRecentListV1: {
@@ -19494,8 +19478,6 @@ export interface operations {
table: string;
/** @description Parent record primary key. */
pk?: string;
/** @description Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0. */
perConnection?: boolean;
};
header?: never;
path?: never;
+21 -21
View File
@@ -2,13 +2,13 @@
# 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 Gitea releases of
# QRun-IO/qqq-cli (git.kof22.com), after checking it against the release's SHA256SUMS.
# 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://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh
# 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 --dir /usr/local/bin
#
# By default it installs the newest stable v* release (the channel Homebrew and Scoop follow);
# By default it installs the newest stable cli-v* release (the channel Homebrew and Scoop follow);
# --pre takes the newest release including prereleases. --help lists the options.
set -eu
@@ -16,31 +16,31 @@ set -eu
repo="QRun-IO/qqq-cli"
version="${QQQ_VERSION:-}"
dir="${QQQ_INSTALL_DIR:-$HOME/.local/bin}"
base="${QQQ_DOWNLOAD_BASE:-https://git.kof22.com/$repo/releases/download}"
base="${QQQ_DOWNLOAD_BASE:-https://github.com/$repo/releases/download}"
musl=""
pre=""
dry_run=""
releases_url="${QQQ_RELEASES_URL:-https://git.kof22.com/api/v1/repos/$repo/releases?limit=30}"
releases_url="${QQQ_RELEASES_URL:-https://api.github.com/repos/$repo/releases?per_page=30}"
usage() {
cat <<'USAGE'
Install qqq (the CLI and the terminal UI, one binary) from https://git.kof22.com/QRun-IO/qqq-cli/releases
Install qqq (the CLI and the terminal UI, one binary) from https://github.com/QRun-IO/qqq-cli/releases
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh -s -- [options]
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- [options]
Options (or environment):
--version V (QQQ_VERSION) a release such as 1.0.0; default: the newest stable v* release
--version V (QQQ_VERSION) a release such as 1.0.0; default: the newest stable cli-v* release
--pre (QQQ_PRE=1) with no --version, take the newest release including prereleases
--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)
--dry-run print the version and download URL, change nothing
-h, --help this help
QQQ_DOWNLOAD_BASE release download root (default https://git.kof22.com/QRun-IO/qqq-cli/releases/download),
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:
scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket
scoop install qqq
USAGE
}
@@ -73,7 +73,7 @@ fi
case "$(uname -s)" in
Darwin) os=darwin ;;
Linux) os=linux ;;
MINGW*|MSYS*|CYGWIN*) die "on Windows install with Scoop (scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket; scoop install qqq) or download qqq-windows-x64.exe from https://git.kof22.com/$repo/releases" ;;
MINGW*|MSYS*|CYGWIN*) die "on Windows install with Scoop (scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; 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
@@ -91,26 +91,26 @@ fi
[ -n "${QQQ_PRE:-}" ] && [ "${QQQ_PRE}" != 0 ] && pre=1
if [ -z "$version" ]; then
# The Gitea releases API lists newest first; each release's "tag_name" comes before its "prerelease".
# The releases API lists newest first; each release's "tag_name" comes before its "prerelease".
# Escaped quotes inside release notes never match these patterns.
version="$(fetch_text "$releases_url" | tr ',' '\n' | awk -v pre="$pre" '
/"tag_name": *"/ { tag = $0; sub(/.*"tag_name": *"/, "", tag); sub(/".*/, "", tag); next }
/"prerelease": *(true|false)/ {
if (tag ~ /^v[0-9]/ && (pre != "" || $0 ~ /false/)) { sub(/^v/, "", tag); print tag; exit }
if (tag ~ /^cli-v/ && (pre != "" || $0 ~ /false/)) { sub(/^cli-v/, "", tag); print tag; exit }
tag = ""
}')" || true
if [ -z "$version" ]; then
if [ -n "$pre" ]; then die "could not find a v* release of $repo; pass --version"; fi
die "could not find a stable v* release of $repo; pass --version, or --pre for prereleases"
if [ -n "$pre" ]; then die "could not find a cli-v* release of $repo; pass --version"; fi
die "could not find a stable cli-v* release of $repo; pass --version, or --pre for prereleases"
fi
fi
version="${version#v}"
version="${version#cli-v}"
file="qqq-$os-$arch$musl"
url="$base/v$version/$file"
url="$base/cli-v$version/$file"
if [ -n "$dry_run" ]; then
say "qqq $version: $url (checked against $base/v$version/SHA256SUMS), into $dir/qqq"
say "qqq $version: $url (checked against $base/cli-v$version/SHA256SUMS), into $dir/qqq"
exit 0
fi
tmp="$(mktemp -d 2>/dev/null || mktemp -d -t qqq)"
@@ -118,8 +118,8 @@ trap 'rm -rf "$tmp"' EXIT
trap 'exit 130' INT TERM
say "Downloading qqq $version ($file)"
fetch "$url" "$tmp/$file" || die "download failed: $url${musl:+ (releases since 1.0.1 have no musl build; on musl hosts install the CLI with npm: @qrun-io/qqq-cli)}"
fetch "$base/v$version/SHA256SUMS" "$tmp/SHA256SUMS" || die "download failed: $base/v$version/SHA256SUMS"
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"
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Offer the filled Homebrew formula and Scoop manifest (scripts/update-packaging.mjs) to the tap and the
# bucket as pull requests; a human merges them. Called by the release workflow's publish job.
#
# final version push branch release/qqq-<version> to QRun-IO/homebrew-tap and QRun-IO/scoop-bucket with
# PACKAGES_TOKEN and open a pull request "qqq <version>" in each
# otherwise (prerelease, or the push or the pull request is refused) attach qqq.rb / qqq.json to the
# release instead, for a hand-made pull request (docs/release-runbook.md step 4)
#
# Never fails the release over the tap or the bucket. DRY_RUN=1 clones and commits locally but pushes,
# opens and attaches nothing; it needs only VERSION (and GITHUB_SERVER_URL, default https://git.kof22.com):
# DRY_RUN=1 VERSION=1.2.3 scripts/publish-packaging.sh
#
# Environment: VERSION, GITHUB_SERVER_URL, GITHUB_REPOSITORY, RELEASE_ID, TOKEN (job token, release assets),
# PACKAGES_TOKEN (tap and bucket), DRY_RUN.
set -euo pipefail
server=${GITHUB_SERVER_URL:-https://git.kof22.com}
api="$server/api/v1"
dry=${DRY_RUN:-0}
: "${VERSION:?VERSION is required}"
[[ $dry == 1 ]] || : "${TOKEN:?TOKEN is required}" "${RELEASE_ID:?RELEASE_ID is required}" "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
branch="release/qqq-$VERSION"
title="qqq $VERSION"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
json() { node -e 'const o = {}; for (let i = 1; i < process.argv.length; i += 2) o[process.argv[i]] = process.argv[i + 1];
process.stdout.write(JSON.stringify(o))' -- "$@"; }
field() { node -e 'try { const v = JSON.parse(require("fs").readFileSync(0, "utf8")); const r = [].concat(v)
.find((p) => !process.argv[2] || (p.head && p.head.ref === process.argv[2])); process.stdout.write(String(r ? r[process.argv[1]] ?? "" : "")) }
catch { }' -- "$@"; }
attach() { # attach <file>: the fallback, a hand-made pull request from the release asset
local src=$1 name
name=$(basename "$src")
if [[ $dry == 1 ]]; then echo "DRY RUN: would attach $name to the release"; return 0; fi
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@$src" \
"$api/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$name" ||
{ echo "::warning::could not attach $name to the release"; return 0; }
echo "Attached $name to the release for a hand-made pull request"
}
propose() { # propose <repo> <src> <dest>: branch + pull request; non-zero means fall back
local repo=$1 src=$2 dest=$3 dir="$work/${1##*/}" header=() base pr body
[[ $dry == 1 || -n ${PACKAGES_TOKEN:-} ]] || { echo "PACKAGES_TOKEN is empty"; return 1; }
[[ -n ${PACKAGES_TOKEN:-} ]] && header=(-c "http.extraHeader=Authorization: Basic $(printf 'ci:%s' "$PACKAGES_TOKEN" | base64 | tr -d '\n')")
git "${header[@]}" clone -q --depth 1 "$server/$repo.git" "$dir" || return 1
base=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
git -C "$dir" checkout -q -b "$branch"
mkdir -p "$(dirname "$dir/$dest")"
cp "$src" "$dir/$dest"
git -C "$dir" add "$dest"
if git -C "$dir" diff --cached --quiet; then echo "$repo $base already has qqq $VERSION"; return 0; fi
git -C "$dir" -c user.name=CI -c user.email=ci@kof22.com commit -q -m "$title"
git -C "$dir" --no-pager show --stat --format='%s' HEAD
if [[ $dry == 1 ]]; then
echo "DRY RUN: would push $branch to $repo and open pull request \"$title\" into $base"; return 0
fi
# CI owns release/qqq-<version>: a re-run replaces it.
git -C "$dir" "${header[@]}" push -q --force origin "HEAD:refs/heads/$branch" || { echo "push to $repo refused"; return 1; }
body="Formula/manifest for qqq $VERSION, filled by the qqq-cli release workflow from the release's SHA256SUMS: $server/$GITHUB_REPOSITORY/releases/tag/v$VERSION. Check the sums, then merge."
pr=$(curl -fsS -X POST -H "Authorization: token $PACKAGES_TOKEN" -H 'Content-Type: application/json' \
-d "$(json head "$branch" base "$base" title "$title" body "$body")" "$api/repos/$repo/pulls" 2>/dev/null | field html_url) || pr=''
if [[ -z $pr ]]; then # a re-run: the pull request may already be open
pr=$(curl -fsS -H "Authorization: token $PACKAGES_TOKEN" "$api/repos/$repo/pulls?state=open&limit=50" 2>/dev/null |
field html_url "$branch") || pr=''
fi
[[ -n $pr ]] || { echo "pushed $branch to $repo but could not open the pull request"; return 1; }
echo "Opened $pr"
}
node "$(dirname "$0")/update-packaging.mjs" "${SUMS_FILE:-/tmp/assets/SHA256SUMS}"
for spec in "QRun-IO/homebrew-tap docs/packaging/homebrew-tap/Formula/qqq.rb Formula/qqq.rb" \
"QRun-IO/scoop-bucket docs/packaging/qqq.json bucket/qqq.json"; do
read -r repo src dest <<< "$spec"
# Final versions only: the tap and the bucket follow the stable channel, like scripts/install.sh.
if [[ $VERSION == *-* ]]; then
echo "$repo not updated: $VERSION is a prerelease"; attach "$src"; continue
fi
propose "$repo" "$src" "$dest" || { echo "$repo not updated: falling back to the release asset"; attach "$src"; }
done
+44 -18
View File
@@ -5,20 +5,18 @@
*/
/**
* Release check for the CLI packages. Runs everything a release needs and then prints how to tag it.
* It never tags, pushes or publishes; the release workflow (.gitea/workflows/release.yaml) runs it as
* its gate on the tag and then publishes.
* 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 tag v<CLI version>
* is free, or already points at this commit (the release workflow runs on the tag)
* 2. npm ci, then the CI steps: npm run generate with no drift in
* 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 ships inside the binaries)
* 3. npm pack --dry-run for each published package
* 4. the single-file binaries with the terminal UI (scripts/build-binaries.mjs; --cli-only leaves it
* out) and dist/SHA256SUMS
* 5. prints how to tag the release
* 5. prints the tag, GitHub release and npm publish commands
*
* A final (non-prerelease) version also has to pass the readiness gate (scripts/readiness.mjs: parity
* 100% in both columns, every server finding filed, a CHANGELOG entry, documented commands exist);
@@ -42,6 +40,11 @@ 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']
/** A literal text as a sed basic regular expression (its metacharacters and the `/` delimiter escaped). */
function sedPattern(text) {
return text.replace(/[\\/.*[\]^$]/g, '\\$&')
}
function fail(message) {
console.error(`\nrelease: ${message}`)
process.exit(1)
@@ -59,8 +62,10 @@ function step(title, command, args) {
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 tag = `v${cli.version}`
const orbTag = `v${client.version}`
const cliTag = `cli-v${cli.version}`
if (argv.has('--check')) {
if (!existsSync(join(root, 'packages/cli/dist/program.js'))) step('build', 'npm', ['run', 'build'])
@@ -74,8 +79,8 @@ for (const { name, version } of Object.values(versions)) console.log(` ${name.p
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)`)
if (git('tag', '--list', tag) && !git('tag', '--points-at', 'HEAD').split(/\s+/).includes(tag)) {
fail(`tag ${tag} already exists on another commit; bump the versions first`)
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')
@@ -97,21 +102,42 @@ 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('--cli-only') ? ['--cli-only'] : [])])
const registry = 'https://npm.pkg.github.com'
// the orb publishes all four packages as rc when any of them is a prerelease, and as latest only when
// all four are final; the GitHub release follows the CLI version
const prerelease = Object.values(versions).some((p) => p.version.includes('-'))
const distTag = prerelease ? 'rc' : 'latest'
const releaseKind = prerelease ? '--prerelease' : '--latest'
console.log(`
All checks passed at ${branch}@${sha}. Nothing was tagged or published. To release, with this commit
merged to main, tag the merge commit and push the tag:
All checks passed at ${branch}@${sha}. Nothing was tagged or published. To release:
git tag -s ${tag} -m "qqq ${cli.version}" && git push origin ${tag}
# 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 ${distTag} (the orb checks ${orbTag} = client version).${prerelease ? '' : '\n # All four versions are final, so the orb publishes them as latest (qqq-orb with PR #165).'}
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}
The release workflow (.gitea/workflows/release.yaml) then reruns this check, builds the binaries (Linux
targets in the container, macOS arm64 and Windows x64 on native runners), publishes the four packages to
the QRun-IO Gitea npm registry with dist-tag ${prerelease ? 'next' : 'latest'}, creates the Gitea ${prerelease ? 'prerelease' : 'release'} ${tag} with
the binaries and SHA256SUMS${prerelease ? '' : ', and updates the Homebrew tap and the Scoop bucket'} (docs/release-runbook.md).
# 2. until the orb release with PR #165, the orb does not publish @qrun-io/qqq-core, and the CLI
# depends on it: publish it by hand right after the orb job, or the CLI cannot be installed.
npm publish -w ${versions['packages/core'].name} --registry ${registry} --tag ${distTag}
# without the orb (all four, in dependency order):
${PUBLISHED.map((dir) => ` npm publish -w ${versions[dir].name} --registry ${registry} --tag ${distTag}`).join('\n')}
# 3. the binaries with their checksums (${prerelease ? "a prerelease" : "the latest release"})
gh release create ${cliTag} --repo QRun-IO/qqq-cli ${releaseKind} --title "qqq ${cli.version}" \\
--notes-file ${existsSync(join(root, `docs/release-notes/${cli.version}.md`)) ? `docs/release-notes/${cli.version}.md` : `<(sed -n '/^## \\[${sedPattern(cli.version)}\\]/,/^## \\[/p' CHANGELOG.md | sed '$d')`} \\
dist/qqq-* dist/SHA256SUMS
# 4. node scripts/update-packaging.mjs <SHA256SUMS of the GitHub release>, then copy
# docs/packaging/homebrew-tap/Formula/qqq.rb to the tap and docs/packaging/qqq.json to the Scoop
# bucket (docs/release-runbook.md steps 7 and 8). With the certificates, sign the macOS and Windows
# binaries and recompute dist/SHA256SUMS before step 3 (runbook step 4).
`)
if (!report.ready) {
console.log('Readiness for a final release (not required for this prerelease):')
printReport(report, { limit: 5 })
console.log('')
}
console.log('Note: every package version must be new in the registry; bump any that is already published.')
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.`)
+3 -6
View File
@@ -7,9 +7,8 @@
/**
* Fills the version and sha256 values of the packaging files (docs/packaging/homebrew-tap/Formula/qqq.rb for Homebrew,
* docs/packaging/qqq.json for Scoop) from packages/cli/package.json and a release's SHA256SUMS.
* The release workflow (.gitea/workflows/release.yaml) runs it on the SHA256SUMS of the binaries uploaded to
* the Gitea release, never on a local rebuild: the commit is stamped into each binary, so every build has
* different checksums.
* 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.
*
* Refuses to write anything (exit 1, both files untouched) when a sum is missing, is not 64 lowercase hex digits,
* is all zeros, or when a placeholder in the files is left unfilled.
@@ -21,8 +20,6 @@ import { readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const HEX64 = /^[0-9a-f]{64}$/
/** Release downloads on Gitea: <RELEASES>/v<version>/<file> (the release tag is v<CLI version>). */
export const RELEASES = 'https://git.kof22.com/QRun-IO/qqq-cli/releases/download'
const root = resolve(import.meta.dirname, '..')
const FORMULA_SUMS = /(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")([0-9a-f]{64})"/g
@@ -61,7 +58,7 @@ export function updatePackaging({ version, sums, formula, manifest }) {
const parsed = JSON.parse(manifest)
parsed.version = version
parsed.architecture['64bit'].url = `${RELEASES}/v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].hash = sumFor(sums, 'qqq-windows-x64.exe')
return { formula: newFormula, manifest: JSON.stringify(parsed, null, 4) + '\n', formulaCount }
}
+1 -2
View File
@@ -20,7 +20,7 @@ const sumsText = (omit = []) =>
[...FILES, 'qqq-linux-x64-musl', 'qqq-windows-x64.exe'].filter((f) => !omit.includes(f)).map((f, i) => `${'abcdef0123456'[i].repeat(64)} ${f}`).join('\n') + '\n'
const formula = `class Qqq < Formula
version "0.0.0"
${FILES.map((f) => ` url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
${FILES.map((f) => ` url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
end
`
const manifest = JSON.stringify({ version: '0.0.0', architecture: { '64bit': { url: 'x', hash: ZERO } } }, null, 4) + '\n'
@@ -32,7 +32,6 @@ test('fills every sum from shasum output and never leaves zeros', () => {
assert.match(out.formula, /version "1\.0\.0"/)
assert.ok(out.formula.includes(`qqq-darwin-arm64"\n sha256 "${h('a')}"`))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].hash, h('f'))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].url, 'https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.0.0/qqq-windows-x64.exe')
})
test('a missing target throws', () => {
-1
View File
@@ -4,7 +4,6 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,