Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ jobs:
- name: Install dependencies
run: npm ci

# src/generated is committed so review sees what a spec change does to
# the CLI; this is what keeps it from drifting from spec/openapi.yaml.
- name: Generated code matches spec/openapi.yaml
shell: bash
run: |
npm run generate
git diff --exit-code --stat -- src/generated
test -z "$(git status --porcelain -- src/generated)"

- name: Typecheck
run: npm run typecheck

Expand Down
127 changes: 127 additions & 0 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: Sync openapi.yaml

# Once a day the published openapi.yaml is fetched. When it differs from
# spec/openapi.yaml, src/generated is regenerated from it and a pull request
# opened, so what API v1 now takes reaches the CLI through review rather than
# through a user's error.
#
# The commands are not generated: which ones exist stays the CLI's decision.
# What follows the spec is what a request may carry — paths, parameters, the
# values a flag takes — so the pull request is where someone sees what moved.
#
# A pull request is opened even when the CLI no longer builds or passes against
# the new spec, and says so. That is the case that needs a person most: an
# operation the CLI calls was removed or renamed, or a value it sends is no
# longer taken. The run then fails as well, so it does not pass unnoticed.
on:
schedule:
- cron: '17 1 * * *' # 09:17 Asia/Shanghai
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: spec-sync
cancel-in-progress: false

jobs:
sync:
name: Fetch openapi.yaml and open a pull request
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm

- name: Install dependencies
run: npm ci

- name: Fetch openapi.yaml
shell: bash
run: |
cp spec/openapi.yaml "${RUNNER_TEMP}/openapi.before.yaml"
node scripts/fetch-spec.mjs

- name: Regenerate src/generated
id: generate
continue-on-error: true
shell: bash
run: npm run generate 2>&1 | tee "${RUNNER_TEMP}/generate.log"

- name: Check the CLI against the new spec
id: check
if: steps.generate.outcome == 'success'
continue-on-error: true
shell: bash
run: |
npm run check 2>&1 | tee "${RUNNER_TEMP}/check.log"
npm test 2>&1 | tail -n 60 | tee -a "${RUNNER_TEMP}/check.log"

- name: Describe the change
id: describe
shell: bash
env:
GENERATED: ${{ steps.generate.outcome }}
CHECKED: ${{ steps.check.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# A change to an operation the CLI calls is a feat and names it, so the
# changelog says what moved; anything else is a chore and releases nothing.
title="$(node scripts/spec-diff.mjs --title "${RUNNER_TEMP}/openapi.before.yaml" spec/openapi.yaml)" ||
title='feat: follow openapi.yaml'
delimiter="EOF_$(openssl rand -hex 8)"
{
echo "title=${title}"
echo "body<<${delimiter}"
echo 'The published openapi.yaml changed, and `src/generated` was regenerated from it.'
echo
node scripts/spec-diff.mjs "${RUNNER_TEMP}/openapi.before.yaml" spec/openapi.yaml ||
echo '- The two copies could not be compared; read the diff of `spec/openapi.yaml`.'
echo
if [ "${GENERATED}" != 'success' ]; then
echo '**`npm run generate` failed**, so `src/generated` still describes the old spec:'
echo
echo '```'
tail -n 30 "${RUNNER_TEMP}/generate.log"
echo '```'
elif [ "${CHECKED}" != 'success' ]; then
echo '**The CLI does not pass against the new spec.** Fix it on this branch before merging:'
echo
echo '```'
grep -E 'error|✖|not ok|AssertionError|expected|actual' "${RUNNER_TEMP}/check.log" | head -n 40 || true
echo '```'
else
echo '`npm run check` and `npm test` pass against the new spec.'
fi
echo
echo "Run: ${RUN_URL}"
echo "${delimiter}"
} >> "${GITHUB_OUTPUT}"

- name: Open or update the pull request
uses: peter-evans/create-pull-request@v7
with:
# A pull request opened with GITHUB_TOKEN does not trigger other
# workflows, so CI would not run on it. The PAT release-please uses
# (contents and pull-requests write) fixes that, as it does there;
# without it the pull request still opens and simply shows no checks.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
branch: chore/sync-openapi
commit-message: ${{ steps.describe.outputs.title }}
title: ${{ steps.describe.outputs.title }}
body: ${{ steps.describe.outputs.body }}
labels: openapi-sync
delete-branch: true

- name: Fail the run when the CLI does not follow the new spec
if: steps.generate.outcome != 'success' || steps.check.outcome == 'failure'
run: |
echo '::error::the CLI does not build or pass against the new openapi.yaml; see the pull request'
exit 1
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"printWidth": 120,
"singleQuote": true,
"trailingComma": "none",
"ignorePatterns": ["CHANGELOG.md", "package-lock.json"]
"ignorePatterns": ["CHANGELOG.md", "package-lock.json", "spec/openapi.yaml"]
}
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off"
},
"ignorePatterns": ["dist"],
"ignorePatterns": ["dist", "src/generated"],
"env": { "builtin": true }
}
95 changes: 83 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,22 @@ says what the CLI does; this says how, for whoever changes it next.
`node:http` are enough. The install is the package and nothing else.
- **The server owns its vocabulary.** Field types, scenes, operators and
aggregation functions are the API's to define and to refuse. The CLI checks
shapes and passes words through.
shapes and passes words through; the words its own flags offer are read from
the openapi.yaml the API publishes, never kept by hand.
- **openapi.yaml says what a request may carry.** Which commands exist is the
CLI's decision; which operation sits at which path, which parameters it has
and what values they take is the spec's, read from a table generated from it
rather than written down a second time.

## Module map

```
spec/openapi.yaml the published API v1 spec, verbatim; spec-sync keeps it current
scripts/
fetch-spec.mjs downloads it
generate.mjs spec/openapi.yaml -> src/generated/operations.ts; the one place that reads OpenAPI
spec-diff.mjs what changed between two copies, for the spec-sync pull request

src/
cli-bin.ts #!/usr/bin/env node — runs runCli, writes the result, sets the exit code
cli.ts runCli: find the command, split the line, dispatch; the --all paging loop
Expand All @@ -37,6 +48,12 @@ src/
local.ts the auth and config commands, in the table so they have help
local.ts runs the auth and config commands (they never reach the API)
options.ts the shared option grammar, and the parsers for --filter, --sort, --metric
spec.ts call(): an operation as a request, path and query checked against the spec;
the values a flag takes
testing/
conformance.ts the tests' check of a request against the spec; not shipped
generated/
operations.ts every operation and the request-side schemas, generated; never edited by hand
payload.ts the shape check on a form or table payload
http.ts JinshujuHttpClient: auth header, deadline, retry, query serialization
auth.ts OAuth with PKCE on a loopback port; refresh; revoke
Expand All @@ -51,8 +68,9 @@ src/
```

Dependencies point downward: `cli.ts` knows everything, `commands/` knows
`options`, `http` and `render` types, `http.ts` knows `config`, `auth` and
`errors`, and `errors.ts`, `values.ts`, `version.ts` know nothing.
`options`, `spec`, `http` and `render` types, `spec.ts` knows the generated
table and `errors`, `http.ts` knows `config`, `auth` and `errors`, and
`errors.ts`, `values.ts`, `version.ts` know nothing.

## A request, end to end

Expand All @@ -69,7 +87,8 @@ Dependencies point downward: `cli.ts` knows everything, `commands/` knows
and `--json` reads inline JSON, `@file` or `-` for stdin right there.
4. `auth` and `config` go to `local.ts`. Everything else goes to `runRemote`,
which builds the `HttpClient` from config and either calls the command's
`run` or sends its `request`.
`run` or sends its `request`. Either way the request was made by
`spec.call`, which refuses a parameter or a value the spec would.
5. `JinshujuHttpClient.request` adds the credential, a `User-Agent` and a
deadline, serializes the query, and sends. A 401 on a stored OAuth session
refreshes the token and tries once more. A retryable status is tried again
Expand All @@ -92,7 +111,7 @@ A `Command` is data, not a class:
| `path` | the words that select it: `['entry', 'list']` |
| `summary`, `description`, `examples`, `payload` | what help shows; nothing else is |
| `args`, `options` | what it accepts; anything not listed is refused |
| `request(input)` | the one request the command is, for the commands that are one |
| `request(input)` | the one request the command is, for the commands that are one, made with `call` |
| `run(input, client)` | for the commands that are more than one request, or must decide how many; returns what to print |
| `paginate` | which keys hold the rows and the cursor, so `--all` can follow it |
| `select`, `render` | narrow the payload; reshape it for text only |
Expand All @@ -105,9 +124,60 @@ request needs, and for `entry import --wait`. Each of those says in its error
exactly what did and did not happen, because "nothing happened" would invite
the caller to do it again.

Two command files decide **what** is sent; nothing in them decides **how**.
Query strings, credentials, timeouts, retries and error shapes are the
client's business, which is why `HttpRequest.query` is values and not a string.
The command files decide **what** is sent; nothing in them decides **how**.
Paths and the spelling of a parameter are the spec's, through `call`; query
strings, credentials, timeouts, retries and error shapes are the client's,
which is why `HttpRequest.query` is values and not a string.

## The spec

`spec/openapi.yaml` is the published spec, copied verbatim. `npm run generate`
reads it with `scripts/generate.mjs` and writes `src/generated/operations.ts`:
every operation's method, path, parameters and request body, and the named
schemas those refer to, cut down to their constraints. Descriptions, examples
and responses stay behind. The output is committed so a spec change reaches
review as a change to what the CLI may send, and CI regenerates it to prove the
two agree. A schema keyword the generator does not know stops it rather than
being dropped: the tests hold requests to what it keeps, and a dropped keyword
would let one through that the spec refuses.

A command names an operation, never a path: `call('listEntries', { path,
query, body })`. A form and a table are two operations in the spec and one
command here, and `onContainer` picks between them by `--form` or `--table`.
`call` checks path and query against the operation before anything is sent —
a parameter it does not have, a required one left out, a value not of its
type — and spells a list the way the parameter says it goes on the wire. The flags
that offer a fixed set of values take them from the spec too, through
`choices` and `bodyChoices`, so a value the API learns reaches the CLI with the
next sync instead of being refused by a copy.

A body goes as given. API v1 validates every request against the openapi.yaml
it runs — the only copy never out of date — and its 400 already says, by
pointer, what is wrong. A check here would save that round trip, but an
installed CLI keeps the spec it shipped with until someone upgrades it, and
from then on it would refuse each field type, key and value the API learns: an
old client is always stricter than the API it talks to. Path and query are
different. They come from the CLI's own flags, a new parameter needs a new
release anyway, and they are held only to their type, never to a range or a
list that may grow.

The tests hold every request the command tests send, and every example in the
help, to the spec, the body included, with `src/testing/conformance.ts`, which
ships with nothing. That is where a body the CLI puts together itself is
checked, and how a `form edit` example sending an `exam_setting` key the API
has no use for was found.

When the spec changes under the CLI, the failure is loud: an operation the CLI
calls that was removed or renamed no longer typechecks, and a value the CLI
sends that is no longer taken fails a test.

`.github/workflows/spec-sync.yml` fetches the published spec every day. When it
differs, it regenerates, runs `check` and `test` against it, and opens a pull
request on `chore/sync-openapi` listing the operations and schemas that moved
and which of them the CLI calls. Its title comes from the same comparison: a
change to an operation the CLI calls is a `feat` naming those operations, and
releases; anything else is a `chore`, and does not. The pull request is opened even when the CLI no
longer passes, and says so: that is the case that needs a person.

## Errors and exit codes

Expand Down Expand Up @@ -159,15 +229,16 @@ character.
`npm test` builds and runs `node --test` over `dist/`. Most tests call
`runCli` with a mock `HttpClient` and assert on the request that would have
been sent and the result that came back: no network, no config file, no
process state. The HTTP client and OAuth are tested against `node:http`
process state. The mock also holds every request to the spec — an operation it
has, a body it takes — and the suite fails if one was not. The HTTP client and OAuth are tested against `node:http`
servers on a loopback port. A temporary config path is passed everywhere so
the suite never reads the developer's own credentials.

## Tooling and release

- `npm run check` is typecheck, `oxlint` and `oxfmt --check`; CI runs the same
on Node 20 and 22, then packs the tarball, installs it and starts both
binaries.
- `npm run check` is typecheck, `oxlint` and `oxfmt --check`; CI regenerates
`src/generated` and fails on any difference, runs the same checks on Node 20
and 22, then packs the tarball, installs it and starts both binaries.
- Commits are conventional commits, and PRs are squash-merged so each lands as
one. release-please keeps a release PR open from `feat` and `fix` commits;
merging it tags, releases and publishes to npm through OIDC trusted
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,10 @@ npm install
npm test
npm run check # typecheck + oxlint + oxfmt --check, what CI runs
npm run fmt # format in place with oxfmt
npm run generate # src/generated from spec/openapi.yaml
npm run spec:update # fetch the published openapi.yaml, then generate
```

What a request may carry — paths, parameters, the values a flag takes — follows
`spec/openapi.yaml`, a copy of the published API v1 spec. A daily workflow
fetches it and opens a pull request when it changes.
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"files": [
"dist",
"!dist/**/*.test.js",
"!dist/**/*.test.d.ts"
"!dist/**/*.test.d.ts",
"!dist/testing"
],
"type": "module",
"publishConfig": {
Expand All @@ -34,13 +35,16 @@
"lint": "oxlint",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"check": "npm run typecheck && npm run lint && npm run fmt:check"
"check": "npm run typecheck && npm run lint && npm run fmt:check",
"generate": "node scripts/generate.mjs && oxfmt src/generated",
"spec:update": "node scripts/fetch-spec.mjs && npm run generate"
},
"devDependencies": {
"@types/node": "^22.10.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"yaml": "^2.9.1"
},
"engines": {
"node": ">=20"
Expand Down
Loading
Loading