diff --git a/.gitignore b/.gitignore index a0c4fb7..1c67b92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ notecard-schema/ dist/ -.DS_Store \ No newline at end of file +.DS_Store + +# Local workspaces can link SDK changes from a sibling hub checkout. +go.work +go.work.sum diff --git a/README.md b/README.md index 49f341a..5fee3d8 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,324 @@ # Notecard & Notehub CLI -This repository contains command-line tools for working with the Notecard and Notehub CLI utilities. +This repository holds two command-line tools: `notecard`, for a Notecard attached to this +computer, and `notehub`, for the Notehub service. ## Installing -The Notecard & Notehub CLIs can be installed either with a package manager (`homebrew`) or by downloading the binaries from the [releases page](https://github.com/blues/note-cli/releases). - -### Homebrew +Install with Homebrew, or download a binary from the +[releases page](https://github.com/blues/note-cli/releases). ```bash brew install --cask blues/note-cli/note-cli ``` > [!IMPORTANT] -If you are upgrading from a version older than v1.9.1 to a newer version, using `brew`, you will need to uninstall first using `brew uninstall note-cli`. +> If you are upgrading with Homebrew from a version older than v1.9.1, uninstall first with +> `brew uninstall note-cli`. + +Each release has builds of both tools for several operating systems and architectures. If +yours is missing, file an issue and we'll add it. + +## Using the Notecard CLI + +``` +notecard [options] ['{"req":"..."}'] +``` + +`notecard` by itself shows how to get going, the saved settings, and the ports it can see, +with the saved one marked. `notecard --help` describes every option, with examples. + +```bash +notecard --interface serial --port /dev/ttyUSB0 # save the port the Notecard is on +notecard '{"req":"card.version"}' # send a request to the Notecard +notecard --req '{"req":"card.version"}' # the same, with the request as an option +notecard --info # what the Notecard knows about itself +notecard --explore # its notefiles, and the notes in each +notecard --trace # watch its trace output +``` + +A request is in the Notecard's own JSON form, given as the last argument or with `--req`, +and the Notecard's answer is written as JSON, indented with `--pretty`. Put options before +the request, since option parsing stops at the first argument that isn't one; a standalone +`--` ends it too. Value options take `--port /dev/ttyUSB0` or `--port=/dev/ttyUSB0`, and +boolean options take `--verbose` or `--verbose=false`. + +Running `notecard --interface serial --port /dev/ttyUSB0` alone saves the interface and +port for the commands that follow. With any other option, or a request, they apply to that +one command. `NOTE_INTERFACE`, `NOTE_PORT` and `NOTE_PORT_CONFIG` in the environment +override the saved ones. + +## Using the Notehub CLI + +``` +notehub [options] [request] +notehub [options] [arguments] +``` + +Everything `notehub` does needs Notehub API access. Sign in once with `notehub login`, +which opens your browser. An agent signs in without a browser, either with +`notehub login --json` or by setting the `NOTEHUB_TOKEN` environment variable to a +personal access token; while that variable is set, its token is used for every request, +whatever login is saved. + +The first word on the command line may name a mode. Without one, `notehub` sends a request +to the hub in the Notecard's own JSON form, or works on a device's notefiles, environment +variables, and provisioning. + +| Mode | Purpose | +| --- | --- | +| *(none)* | Send a request, list a device's notefiles, manage environment variables, provision devices | +| `login`, `logout` | Sign into a hub, or out of it | +| `auth` | Manage logins: `login`, `logout`, `status`, `switch`, `token` | +| `api` | Send one request to the Notehub API, the way `gh api` does for GitHub | +| `firmware` | Manage a project's host firmware, and apply it to devices | +| `skills` | Read and manage the skills a project holds | + +`notehub` by itself lists the modes and the saved settings, without examples. +`notehub --help` adds every option of the default mode and its examples, and +`notehub --help` describes a mode, with examples. A mode given nothing to do +shows abbreviated help too. `notehub --help-agent` prints the agent guide embedded +from [`notehub/help-agent.md`](notehub/help-agent.md). +`notehub --agent-chat` fetches additional agent guidance from `https://chat.notehub.io`. + +Value options take `--project app:123` or `--project=app:123`, boolean options take +`--pretty` or `--pretty=false`, and a standalone `--` ends option parsing. In the default +mode, options come before the request. In a mode, they may come before or after a +command's arguments. + +Running `notehub --hub example.com` alone makes that hub the active one for the commands +that follow. With anything else on the command line, `--hub` applies to that command only. + +### Signing in + +The CLI keeps one login per hub. Each is listed in Notehub's API Access as +`Notehub CLI - `, with your computer's name unless `--name` gives another. + +```bash +notehub login # in your browser +notehub login --name "Ray's laptop" # under a name of your choosing +echo "$SECRET" | notehub login --with-token # with a personal access token, kept out of your shell history +notehub login --hub preprod.blues.tools # into another hub, which stays inactive +notehub logout # out of the active hub +notehub auth status # check the active connection's login +notehub auth status --all # check every saved login, and show which hub is active +notehub auth switch preprod.blues.tools # make a hub you have signed into the active one +notehub auth token # print the token in use, for a program +``` + +`notehub auth login` and `notehub auth logout` are the same commands as `notehub login` +and `notehub logout`. A login leaves the active hub as it is; `auth switch` changes it. +A login that Notehub still accepts is kept: `notehub login` checks it and exits with +success without opening anything. A revoked session requires new browser approval. +Interactive login falls back to the localhost callback when polling is unavailable, +and retries with the original scopes if the service refuses `offline_access`. +`auth status` checks only the active connection, or the one given with `--hub`, against +the service. It exits 0 when that connection accepts its login, and 1 otherwise. +With `--json` it writes that connection's status as one object with `hub`, `active`, +`signed_in`, and any available identity or error details, and exits 0; inspect +`signed_in` to determine whether authentication succeeded. Add `--all` to check every +saved login; `--all --json` returns an object with the active hub and a `hubs` array. +`--all` and `--hub` cannot be combined. + +Browser logins retain an OAuth refresh token when the service issues one. All +authenticated Notehub operations use shared renewal before sending a request, +including status checks, files, firmware, skills, and legacy commands. Access tokens +within a minute of expiration are renewed; a replayable request retries once after +a 401 when renewal succeeds. Rotated tokens are saved atomically under a process +lock. PATs, including `NOTEHUB_TOKEN`, are not refreshed. Deleting the OAuth entry +in Notehub prevents further API access even with refreshed tokens; `notehub login` +then requires new approval. Sessions without refresh tokens remain usable until +their access tokens expire. + +An agent signs in with `--json`, which opens nothing and types nothing: + +```bash +notehub login --name "DotBot" --json # the URL to give the user, progress, and the result +echo "$SECRET" | notehub login --with-token --json # with a token: the result alone +``` + +The output is one JSON object per line: the URL to hand to the user, with +`expires_in_seconds` for how long the hub will honor it; a progress line every ten seconds +with the seconds remaining; and a final line with `success` and `status`. With a login +already in place, the final line is all there is. `--expires-in` asks the hub for a +shorter window than its usual ten minutes. +If polling is unavailable, the JSON flow returns instructions for the user to sign +in interactively on the CLI's machine, or to supply `NOTEHUB_TOKEN`. + +### Requests, notefiles, environment variables and provisioning + +With no mode, `notehub` works on one project, named by `--project` or `--product`, and +often one device, named by `--device`. + +```bash +notehub --projects --pretty # the projects you belong to +notehub --product com.example:sensor --device dev:864475040518622 '{"req":"note.get","file":"hub.db","note":"config"}' +notehub --product com.example:sensor --device dev:864475040518622 --explore # the device's notefiles and notes +notehub --product com.example:sensor --scope "Field Units" --get-vars --pretty # a fleet's environment variables +notehub --product com.example:sensor --scope dev:864475040518622 --set-vars '{"interval":"60","debug":"-"}' +notehub --product com.example:sensor --scope dev:864475040518622 --provision --sn SN-0042 +``` + +A request is in the Notecard's own JSON form, given as the last argument or with `--req`, +and `@file` reads it from a file. The hub answers as the Notecard would, and the answer is +written as JSON, indented with `--pretty`. + +`--scope` names the devices or fleets that `--get-vars`, `--set-vars`, `--provision` and +`--explore` concern: `dev:` and `fleet:` UIDs, a fleet's name, `@fleet` for a fleet's +devices, `@file` for a file listing any of these one per line, or `devices` and `fleets` +for all of them, separated by commas. A variable set to `-` is removed. + +### API requests + +`notehub api` sends one request to the Notehub API, authorized as you, and writes the +response body to stdout, the way `gh api` does for GitHub: + +```bash +notehub api /v1/projects # a GET +notehub api /v1/projects/app:123/devices # the project written into the path +notehub api /v1/projects/{project}/devices --project app:123 # or --project filling in {project} +notehub api /v1/projects/{project}/devices --paginate --project app:123 # every page +notehub api PUT /v1/projects/{project}/devices/{device}/environment_variables --input vars.json --project app:123 --device dev:abc +cat fleet.json | notehub api POST /v1/projects/{project}/fleets --input - --project app:123 # a body from stdin +notehub api /v1/projects/{project}/fleets --field label=Field --project app:123 # parameters make a POST +notehub api GET /v1/projects/{project}/events --field pageSize=5 --project app:123 # or a GET's query string +notehub api /v1/projects --include # the status line and headers too +notehub api --spec # the hub's OpenAPI spec +``` + +Write the path as the API documents it, with the UIDs in it, or write `{project}`, +`{product}` or `{device}` in their place and let `--project`, `--product` or `--device` +fill them in. Without a placeholder, those options aren't needed. The method is GET unless +one is given, or a body is sent, which makes it POST. + +Parameters given with `--field key=value` are typed as `gh` types them: `true`, `false`, +`null` and numbers as such, `@file` for a file's contents and `@-` for stdin. `--raw-field` +keeps a string as it is. Parameters go in a JSON body, or in the query string when the +method given is GET. `--input` sends a file, or stdin with `-`, as the body, and `--header` +adds a header. -### Downloading the binaries +The response is written as it arrives, for as long as the hub keeps sending, unless +`--pretty` must read it whole. The exit code is 0 when the hub answered with success, and 1 +when it answered with an error, whose body is still written, or when it couldn't be +reached. `--paginate` follows `has_more` through every page of a GET, writing each. `--spec` +shows the hub's OpenAPI spec, from `https:///openapi.yaml`, which describes every +endpoint. -For all releases, we have compiled the Notecard and Notehub utilities for different OS and architectures [here](https://github.com/blues/note-cli/releases). +### Firmware -If you don't see your OS and architecture supported, please file an issue and we'll add it to new releases. +`notehub firmware` manages the host firmware a project holds, and applies it to devices: -## Building the CLIs +```bash +notehub firmware --project app:123 # what the project holds +notehub firmware show 1.2.3 --project app:123 # one firmware, with what its binary says +notehub firmware set ./build/app.bin --notes @RELEASE.md --project app:123 # upload, version read from the binary +notehub firmware set ./build/app.bin --version 1.2.3 --project app:123 # or given +notehub firmware get 1.2.3 ./app-1.2.3.bin --project app:123 # download, verified against its md5 +notehub firmware edit 1.2.3 --notes "fixes the sensor timeout" --project app:123 +notehub firmware delete app$20260324190911.bin --project app:123 +notehub firmware update 1.2.3 --scope "Field Units" --project app:123 # apply it to a fleet +notehub firmware status --scope "Field Units" --project app:123 # each device's update, as it goes +notehub firmware history --scope dev:864475040518622 --project app:123 # and as it went +notehub firmware update 1.2.3 --scope all --force --project app:123 # every device in the project +``` + +A firmware is named by its stored filename, such as `app$20260324190911.bin`, or by its +original name or its version when that names just one. `set` skips a binary the project +already holds byte for byte, and replaces one of the same name and version, uploading the +new one before removing the old, unless `--no-replace` refuses that. + +`--scope` names devices, fleets, a fleet's name, or `@file` of them, and `all` with +`--force` addresses every device in the project; `--tag`, `--sn` and `--sku` narrow it +further. `--dry-run` says what `set`, `edit`, `delete`, `update` or `cancel` would do, +without doing it, and `--json` writes a listing, metadata, status or history as JSON. + +### Skills + +A project's skills are Markdown files, stored in the project, that tell an AI agent what +the product is and what its data means. To write them, point any AI agent at +https://notehub.md and ask it to train your project. The `skills` mode reads and manages +them: + +```bash +notehub skills --project app:123 # every skill, assembled into one linked document +notehub skills list --project app:123 # what the project holds, by name, size, date and kind +notehub skills show index.md --project app:123 # one skill, exactly as stored +notehub skills get index.md - --project app:123 # the same, as 'get' writes it to stdout +notehub skills get all ./skills --project app:123 # copy every skill into ./skills +notehub skills set ./skills --dry-run --project app:123 # what storing them back would change +notehub skills set ./skills --project app:123 # store the ones that changed +notehub skills rename old.md new.md --project app:123 # store a skill under another name +notehub skills delete old.md --project app:123 # remove one skill from the project +notehub skills backup skills.zip --project app:123 # save every skill into a zip file +notehub skills restore skills.zip --project app:123 # make the project hold exactly the zip's skills +``` + +A skill's kinds, such as `product` or `recipe`, are stored as its tags, taken from the +`kind:` (or `kinds:` or `tags:`) line of its front matter. To change them, edit that line +and `set` the file again. A backup writes the kinds a skill is stored under into its front +matter when they aren't already there, so a restore keeps them. -### Dependencies +With the V1 Skills API, updates and deletes use the revision that was read. If another +writer changes a skill, the command stops with a conflict; reread and review the change +before retrying. Rename and restore are sequences of conditional operations, so a +failure can leave a partially completed operation. Restore verifies the resulting set +before reporting success. An unchanged `set` still checks write access and its revision. -- Install Go and the Go tools [(here)](https://golang.org/doc/install) +Each V1 read takes one request: the entire metadata catalog, one named document, or +the complete set with content. Writes accept at most 1 MiB of UTF-8 per document and +2 MiB plus 64 KiB of encoded JSON. Full-set reads are capped at 8 MiB of content and +17 MiB of encoded JSON. An oversized bulk read stops with an error; `list` and named +`show`/`get` remain available. Backup stays a single full-set read. -### Compiling the utilities +`get` won't overwrite a local file that differs unless given `--force`, and neither will +`backup`. `set` stores only what differs, and never removes anything. `rename` won't +replace another skill without `--force`, and `delete all` requires it. `restore` stores +what the zip holds and then removes every other skill. `--dry-run` says what `set`, +`rename`, `delete`, `backup` or `restore` would do, without doing it. -If you want to build the latest, follow the directions below. +## Building from source + +With Go 1.25 or later installed: ```bash -cd notecard -go build . +cd notecard && go build . +cd notehub && go build . ``` +The OAuth refresh changes currently use the matching SDK in the sibling `hub` +checkout. From the root of this repository, a local Go workspace connects them: + ```bash -cd notehub -go build . +go work init . ./lib ../hub/note-go +``` + +Workspace files are ignored by Git. Before releasing standalone CLI builds, publish +the updated `note-go` SDK and update its required version in both `go.mod` and +`lib/go.mod`. Existing Notehub deployments support interactive login through the +compatibility fallback. Refresh requires the `notehub_cli` OAuth registration to +allow `offline_access` and `refresh_token`; this is a registration change, not a +new token endpoint. `hydraLauncher` maintains those settings. Polling login also +needs its callback registered, and its service handler must request `offline_access` +to obtain refresh tokens. An older polling handler can still issue ordinary access +tokens. The shared `note-go` token source and request helper are documented in +`note-go/notehub/README.md` in the SDK checkout. + +For testing the new Skills APIs, `notehub/skills-storage.go` contains: + +```go +const useV0SkillApis = false ``` -## Additional Resources +`false` sends every built-in `skills` command through V1, including backup, restore, +rename and dry runs. It requires a hub serving the new Skills endpoints and never +falls back to V0 on an error. Set it to `true` and rebuild to select the original V0 +upload APIs. This switch does not change generic `files --type skill` or raw requests. -To learn more about Blues Wireless, the Notecard and Notehub, see: +## Additional resources -- [blues.com](https://blues.io) -- [notehub.io](https://notehub.io) -- [wireless.dev](https://wireless.dev) +- [blues.com](https://blues.com), about Blues, the Notecard and Notehub +- [dev.blues.io](https://dev.blues.io), the developer documentation +- [notehub.io](https://notehub.io), the Notehub service ## License diff --git a/go.mod b/go.mod index c4bd4cd..33635c9 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,23 @@ go 1.25.0 replace github.com/blues/note-cli/lib => ./lib require ( - github.com/blues/note-cli/lib v0.0.0-20251120160051-d509bdf52531 - github.com/blues/note-go v1.10.0 + github.com/blues/note-cli/lib v0.0.0-00010101000000-000000000000 + github.com/blues/note-go v1.11.1 + github.com/blues/notehub-go v0.0.10 github.com/fatih/color v1.18.0 + github.com/mattn/go-isatty v0.0.20 github.com/peterh/liner v1.2.2 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect periph.io/x/conn/v3 v3.7.3 // indirect ) @@ -31,7 +34,7 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect - go.bug.st/serial v1.7.1 + go.bug.st/serial v1.8.0 golang.org/x/sys v0.43.0 // indirect periph.io/x/host/v3 v3.8.5 // indirect ) diff --git a/go.sum b/go.sum index 7824656..8d4b125 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -github.com/blues/note-go v1.10.0 h1:7lrdU2lmfR8HH8rO4Vfy79jPLVVLPrJXO79zce4387A= -github.com/blues/note-go v1.10.0/go.mod h1:3w70AMZXX26UJmT4UouiJwHi9/ZKmGndU65fbW8a7no= +github.com/blues/note-go v1.11.1 h1:xSmPm9js61ETSoeVteQw2mriYfY0Wq9wY00bDF9gsFY= +github.com/blues/note-go v1.11.1/go.mod h1:vuFSki78WEv1q9Y6nNYwS4s520RvVCsh4/BRHBIQ3q8= +github.com/blues/notehub-go v0.0.10 h1:1D5DQhV2HHt8tpL/K1MKqKkFQz5cj4i02ciAuCJeGFg= +github.com/blues/notehub-go v0.0.10/go.mod h1:iKPyFKDcLE1D1j0lYjA215qu1eB/7C+o7BUKWgmTgX4= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -17,6 +17,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -28,8 +32,6 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= @@ -40,16 +42,18 @@ github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= -go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= +go.bug.st/serial v1.8.0 h1:ZtnmN8aYXtPlTghwSvDWPHKBHL9TM6oFDa+KpSn4SQE= +go.bug.st/serial v1.8.0/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -57,6 +61,11 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= periph.io/x/conn/v3 v3.7.3 h1:+8UblkC4omTB1M+jZTvTj3qoxQOTJy0ZRQm8DLUuVzc= diff --git a/lib/cli/cli.go b/lib/cli/cli.go new file mode 100644 index 0000000..5fd14db --- /dev/null +++ b/lib/cli/cli.go @@ -0,0 +1,966 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +// Package cli is the command line framework shared by the note CLIs. A command line has +// the form +// +// [mode] [switches] [arguments] +// +// where the optional mode keyword selects which switches apply and how the arguments are +// read. With no mode, the tool runs its default mode. A tool describes its modes and +// switches in one table each, and from those the framework registers only the switches of +// the mode being run, names a switch used in the wrong mode as such, dispatches a mode's +// commands, and generates help. +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/blues/note-cli/lib" +) + +// ModeDefault names the mode that runs when no mode keyword is given +const ModeDefault = "default" + +// Help is the word that asks for help where a mode keyword or a mode's command goes, and +// the switch that asks for it anywhere +const Help = "help" + +// AnyMode in a switch's Modes makes it available in every mode +const AnyMode = "*" + +// GroupGeneral is the group of switches that belong to the tool rather than to a mode, +// and the only ones short help shows +const GroupGeneral = "general" + +// Tool describes a command line tool: what it is called, and its modes and switches +type Tool struct { + // Name is the tool's name as typed on the command line + Name string + // Title says what the tool is, on the first line of its help + Title string + // DocsURL is where the tool's documentation lives, or "" for the usual place + DocsURL string + // AgentHelp, if present, is printed by --help-agent and advertised in every help + AgentHelp string + // OmitShortHelpExamples keeps examples in full help only + OmitShortHelpExamples bool + // Modes are the tool's modes, in the order help lists them; one is named ModeDefault + Modes []*Mode + // Switches are the switches of every mode; the framework adds its help switches + Switches []*Switch + // Groups are the help groups, in display order + Groups []Group + // NotecardConfig has lib register --interface, --port and --portconfig, for a tool + // that talks to a Notecard + NotecardConfig bool + // NotehubConfig has lib register --hub, for a tool that talks to Notehub + NotehubConfig bool + // AfterParse, if present, is called whenever switches have been parsed, for a tool + // that reconciles them + AfterParse func() +} + +// Mode is an optional first-argument keyword that selects how the rest is parsed +type Mode struct { + // Name is the keyword typed on the command line + Name string + // Summary is the one-line description used when modes are listed + Summary string + // Args describes this mode's non-switch arguments, for the USAGE line + Args string + // Commands, if present, are this mode's non-switch commands + Commands []Command + // Examples, if present, are displayed after this mode's switches + Examples []Example + // Preamble, if present, follows USAGE and the tool's agent-help notice + Preamble string + // Detail, if present, is displayed at the end of this mode's help + Detail string + // Hidden indicates that this mode works but is not listed as one of the modes + Hidden bool + // Run is the handler for the mode, called after the command line has been parsed + Run func(config *lib.ConfigSettings) error +} + +// Example is one example in a mode's help: what it does, and the command line +type Example struct { + What string + Command string +} + +// Command is a non-switch command word accepted by a mode +type Command struct { + Name string + Args string + Summary string + Run func(config *lib.ConfigSettings, args []string) error +} + +// Group is a help group: its name in a switch's Group, and its heading in help +type Group struct { + Name string + Description string +} + +// Switch is the definition of a single command line switch +type Switch struct { + // Name is the switch without its leading hyphens + Name string + // Target receives the value: a *bool, *string, *int, *OptionalValue or *StringList, + // or nil if External + Target any + // Default is the value when the switch isn't given, or nil for the zero value + Default any + // Usage is the help text; as in the flag package, a `backquoted` word names the value + Usage string + // Group is the help group this switch is displayed in + Group string + // Modes are the modes in which this switch may be used, or AnyMode for all + Modes []string + // External means lib registers this switch; it is here for help and modes + External bool + // ValueType names the switch's value in help output when Target is nil + ValueType string + // Hidden means the switch works but isn't shown in help: a spelling kept for + // compatibility + Hidden bool +} + +// OptionalValue is the target of a switch that may be given with or without a value. Go's +// flag package has no such switches, but one that calls itself boolean isn't made to take +// an argument: it is given "true" when bare, and the value when written as --name=value. +// A value that follows after a space is left as a positional argument, for the command to +// take as it sees fit. +type OptionalValue struct { + // Given reports that the switch was on the command line, with a value or without + Given bool + // Value is the value it was given, or "" when bare + Value string +} + +// String returns the value, as the flag package asks +func (v *OptionalValue) String() string { return v.Value } + +// Set records the switch as given, with its value unless it was bare +func (v *OptionalValue) Set(value string) error { + v.Given = true + if value != "true" { + v.Value = value + } + return nil +} + +// IsBoolFlag tells the flag package not to make the switch take an argument +func (v *OptionalValue) IsBoolFlag() bool { return true } + +// StringList is the target of a switch that may be given more than once, collecting every +// value +type StringList []string + +// String returns the values, as the flag package asks +func (l *StringList) String() string { return strings.Join(*l, ",") } + +// Set adds a value +func (l *StringList) Set(value string) error { + *l = append(*l, value) + return nil +} + +// The tool that is running, with its switches and modes as the framework completes them +var ( + tool *Tool + switches []*Switch + modes []*Mode +) + +// The switches that ask for help +var helpGiven, agentHelpGiven bool + +// Define makes tool the one that is running. Main calls it before anything else, and so +// does a test. A mistake in the tables is a programming error, and is reported as one. +func Define(t *Tool) { + tool = t + if tool.DocsURL == "" { + tool.DocsURL = "https://dev.blues.io/tools-and-sdks/" + tool.Name + "-cli" + } + helpGiven = false + agentHelpGiven = false + switches = []*Switch{{Name: Help, Target: &helpGiven, Group: GroupGeneral, Modes: []string{AnyMode}, + Usage: "display every option available in this mode"}} + if tool.AgentHelp != "" { + switches = append(switches, &Switch{Name: "help-agent", Target: &agentHelpGiven, Group: GroupGeneral, Modes: []string{AnyMode}, + Usage: "display help for agents"}) + } + switches = append(switches, tool.Switches...) + modes = append(append([]*Mode{}, tool.Modes...), &Mode{ + Name: Help, + Summary: "display help for this tool or for one of its modes", + Args: "[mode]", + Hidden: true, + Run: runHelp, + }) + if err := CheckDefinitions(); err != nil { + panic(fmt.Sprintf("%s: %s", tool.Name, err)) + } +} + +// Name returns the tool's name +func Name() string { + return tool.Name +} + +// Modes returns every mode, in the order help displays them +func Modes() []*Mode { + return modes +} + +// VisibleModes returns the modes that are listed when modes are displayed +func VisibleModes() (visible []*Mode) { + for _, mode := range modes { + if !mode.Hidden { + visible = append(visible, mode) + } + } + return +} + +// ModeNames returns the names of the visible modes +func ModeNames() (names []string) { + for _, mode := range VisibleModes() { + names = append(names, mode.Name) + } + return +} + +// ModeNamed returns the named mode, or nil if there is no such mode +func ModeNamed(name string) *Mode { + for _, mode := range modes { + if strings.EqualFold(name, mode.Name) { + return mode + } + } + return nil +} + +// Switches returns every switch, in any mode +func Switches() []*Switch { + return switches +} + +// SwitchNamed returns the named switch in any mode, or nil. A name defined for more than +// one set of modes returns its first definition, which is enough to know how it is parsed. +func SwitchNamed(name string) *Switch { + for _, s := range switches { + if s.Name == name { + return s + } + } + return nil +} + +// SwitchesNamed returns every definition of the named switch: a name may be defined once +// per set of modes, with its own usage and group in each +func SwitchesNamed(name string) (found []*Switch) { + for _, s := range switches { + if s.Name == name { + found = append(found, s) + } + } + return +} + +// Groups returns the help groups, in display order +func Groups() []Group { + return tool.Groups +} + +// CommandNamed returns the named command among some, or nil +func CommandNamed(commands []Command, name string) *Command { + for i := range commands { + if strings.EqualFold(name, commands[i].Name) { + return &commands[i] + } + } + return nil +} + +// CommandLabel is how a command is identified when commands are listed +func CommandLabel(command Command) string { + return strings.TrimSpace(command.Name + " " + command.Args) +} + +// CheckDefinitions reports what is wrong with the tool's tables, or nil +func CheckDefinitions() error { + var problems []error + problem := func(format string, args ...any) { + problems = append(problems, fmt.Errorf(format, args...)) + } + + // The modes + seen := map[string]bool{} + for _, mode := range modes { + if mode.Name == "" { + problem("a mode is defined with no name") + continue + } + if seen[mode.Name] { + problem("the '%s' mode is defined more than once", mode.Name) + } + seen[mode.Name] = true + if mode.Summary == "" { + problem("the '%s' mode has no summary", mode.Name) + } + if mode.Run == nil { + problem("the '%s' mode has no handler", mode.Name) + } + for _, c := range mode.Commands { + if c.Name == "" || c.Run == nil { + problem("the '%s' mode has a command with no name or no handler", mode.Name) + } + } + for _, example := range mode.Examples { + if example.What == "" || !strings.Contains(example.Command, tool.Name+" ") { + problem("the '%s' mode has an example without a description or a command line: %+v", mode.Name, example) + } + } + } + if !seen[ModeDefault] { + problem("there is no '%s' mode", ModeDefault) + } + + // The switches + defined := map[string][]*Switch{} + for _, s := range switches { + if s.Name == "" { + problem("a switch is defined with no name") + continue + } + // A name may be defined once per set of modes, and never twice for one mode + for _, other := range defined[s.Name] { + for _, mode := range modes { + if s.AllowedIn(mode.Name) && other.AllowedIn(mode.Name) { + problem("--%s is defined twice for the '%s' mode", s.Name, mode.Name) + } + } + } + defined[s.Name] = append(defined[s.Name], s) + if len(s.Modes) == 0 { + problem("--%s is not available in any mode", s.Name) + } + for _, m := range s.Modes { + if m != AnyMode && ModeNamed(m) == nil { + problem("--%s is available in '%s', which is not a mode", s.Name, m) + } + } + if !groupExists(s.Group) { + problem("--%s is in group '%s', which is not a group", s.Name, s.Group) + } + // General options are shown by short help, for every mode + if s.Group == GroupGeneral { + if s.Hidden { + problem("--%s is hidden, and so can't be a general option", s.Name) + } + for _, mode := range modes { + if !s.AllowedIn(mode.Name) { + problem("--%s is a general option and so must be available in every mode, including '%s'", s.Name, mode.Name) + } + } + } + if valueName, _ := s.unquoteUsage(); valueName == "string" || valueName == "int" { + problem("--%s doesn't say what its value is: put a `word` for it in its usage", s.Name) + } + switch s.Target.(type) { + case *bool, *string, *int, *OptionalValue, *StringList: + if s.External { + problem("--%s is registered by lib and so must not have a target", s.Name) + } + case nil: + if !s.External { + problem("--%s has no target and so must be registered by lib", s.Name) + } + default: + problem("--%s has an unsupported target type %T", s.Name, s.Target) + } + } + return errors.Join(problems...) +} + +// groupExists reports whether a group is one of the tool's +func groupExists(name string) bool { + for _, group := range tool.Groups { + if group.Name == name { + return true + } + } + return false +} + +// AllowedIn returns true if this switch may be used in the specified mode +func (s *Switch) AllowedIn(modeName string) bool { + for _, m := range s.Modes { + if m == AnyMode || strings.EqualFold(m, modeName) { + return true + } + } + return false +} + +// modeList returns a readable list of the modes in which this switch may be used +func (s *Switch) modeList() string { + names := []string{} + for _, m := range s.Modes { + if m == AnyMode { + return "all modes" + } + names = append(names, m) + } + return strings.Join(names, ", ") +} + +// takesValue returns true if this switch consumes the argument that follows it +func (s *Switch) takesValue() bool { + switch s.Target.(type) { + case *bool, *OptionalValue: + return false + } + if s.Target == nil { + return s.ValueType != "" + } + return true +} + +// unquoteUsage returns the switch's value name and usage as the flag package would: a +// `backquoted` word in the usage names the value, and otherwise its type does +func (s *Switch) unquoteUsage() (valueName string, usage string) { + usage = s.Usage + for i := 0; i < len(usage); i++ { + if usage[i] == '`' { + for j := i + 1; j < len(usage); j++ { + if usage[j] == '`' { + valueName = usage[i+1 : j] + usage = usage[:i] + valueName + usage[j+1:] + return + } + } + break + } + } + switch s.Target.(type) { + case *bool: + valueName = "" + case *int: + valueName = "int" + case *string: + valueName = "string" + case *OptionalValue, *StringList: + valueName = "value" + default: + valueName = s.ValueType + } + return +} + +// displayName is how this switch is displayed in help, such as "product (productUID)" +func (s *Switch) displayName() string { + valueName, _ := s.unquoteUsage() + if valueName == "" { + return s.Name + } + if _, isOptional := s.Target.(*OptionalValue); isOptional { + return fmt.Sprintf("%s [%s]", s.Name, valueName) + } + return fmt.Sprintf("%s (%s)", s.Name, valueName) +} + +// Start begins a run: it finds the mode keyword among args, which main passes as +// os.Args[1:], registers that mode's switches, and makes its help the usage that the flag +// package shows. The keyword is removed from os.Args, so that the flag package and the +// configuration handling see the command line they always have. +func Start(args []string) (mode *Mode, rest []string) { + mode, rest = ExtractMode(args) + os.Args = append([]string{os.Args[0]}, rest...) + RegisterSwitches(mode) + flag.Usage = func() { + PrintHelp(mode, !tool.OmitShortHelpExamples || SwitchAmong(rest, Help) || SwitchAmong(rest, "h")) + } + return mode, rest +} + +// Parse parses a mode's switches, applies the configuration ones, and returns the +// configuration. A command line that asks for help gets it, and the tool exits. Only the +// default mode adopts a --hub given alone as the saved hub; the others act on the hub it +// names. +func Parse(mode *Mode, args []string) (*lib.ConfigSettings, error) { + + // Name a switch used in the wrong mode, rather than calling it undefined + if err := ValidateSwitches(mode, args); err != nil { + return nil, err + } + + // Parse these switches and also lib's configuration switches + if err := lib.FlagParseSaving(tool.NotecardConfig, tool.NotehubConfig, mode.Name == ModeDefault); err != nil { + return nil, err + } + if tool.AfterParse != nil { + tool.AfterParse() + } + + // Get the resulting configuration + config, err := lib.GetConfig() + if err != nil { + return nil, fmt.Errorf("config: %w", err) + } + + printRequestedHelp(mode) + return config, nil + +} + +// ExtractMode returns the mode named by the first argument and the arguments after it, or +// the default mode and all of them. A bare word there can't be anything else, since a +// request is JSON or @filename. '--skills' is accepted too, though help doesn't say so, +// unless it names a real switch, so that '--help' is still the switch. +func ExtractMode(args []string) (mode *Mode, remaining []string) { + if len(args) == 0 { + return ModeNamed(ModeDefault), args + } + name := args[0] + if strings.HasPrefix(name, "-") { + name = strings.TrimPrefix(strings.TrimPrefix(name, "-"), "-") + if !IsBareWord(name) || SwitchNamed(name) != nil { + return ModeNamed(ModeDefault), args + } + } + if mode = ModeNamed(name); mode != nil { + return mode, args[1:] + } + return ModeNamed(ModeDefault), args +} + +// RegisterSwitches registers only this mode's switches with the flag package +func RegisterSwitches(mode *Mode) { + for _, s := range switches { + if s.External || !s.AllowedIn(mode.Name) { + continue + } + switch target := s.Target.(type) { + case *bool: + defaultValue, _ := s.Default.(bool) + flag.BoolVar(target, s.Name, defaultValue, s.Usage) + case *string: + defaultValue, _ := s.Default.(string) + flag.StringVar(target, s.Name, defaultValue, s.Usage) + case *int: + defaultValue, _ := s.Default.(int) + flag.IntVar(target, s.Name, defaultValue, s.Usage) + case *OptionalValue: + flag.Var(target, s.Name, s.Usage) + case *StringList: + flag.Var(target, s.Name, s.Usage) + default: + panic(fmt.Sprintf("--%s is defined with an unsupported target type %T", s.Name, s.Target)) + } + } +} + +// ValidateSwitches refuses a known switch used outside its modes, which the flag package +// alone would report only as "not defined" +func ValidateSwitches(mode *Mode, args []string) error { + for _, name := range ScanSwitchNames(args) { + definitions := SwitchesNamed(name) + if len(definitions) == 0 { + continue + } + available := []string{} + for _, s := range definitions { + if s.AllowedIn(mode.Name) { + available = nil + break + } + available = append(available, s.modeList()) + } + if available == nil { + continue + } + return fmt.Errorf("--%s is not available in '%s' mode (it is available in: %s)", name, mode.Name, strings.Join(available, ", ")) + } + return nil +} + +// ScanSwitchNames returns the switches named in args, up to where the flag package stops +func ScanSwitchNames(args []string) (names []string) { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" || len(arg) < 2 || arg[0] != '-' { + break + } + name := strings.TrimPrefix(arg[1:], "-") + if name == "" || name[0] == '-' || name[0] == '=' { + break + } + if equals := strings.Index(name, "="); equals >= 0 { + names = append(names, name[:equals]) + continue + } + names = append(names, name) + if s := SwitchNamed(name); s != nil && s.takesValue() { + i++ + } + } + return +} + +// SwitchGiven reports whether the named switch was given on the command line, in whichever +// place the mode reads switches from +func SwitchGiven(name string) (given bool) { + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + given = true + } + }) + return +} + +// SwitchAmong reports whether the named switch appears anywhere among args, before they +// have been parsed, and before a standalone "--" +func SwitchAmong(args []string, name string) bool { + for _, arg := range args { + if arg == "--" { + return false + } + if len(arg) < 2 || arg[0] != '-' { + continue + } + switchName := strings.TrimPrefix(arg[1:], "-") + if switchName == name || strings.HasPrefix(switchName, name+"=") { + return true + } + } + return false +} + +// FirstPositional returns the first of args that isn't a switch or a switch's value, +// before any have been parsed, or "" if there is none +func FirstPositional(args []string) string { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if len(arg) < 2 || arg[0] != '-' { + return arg + } + name := strings.TrimPrefix(arg[1:], "-") + if !strings.Contains(name, "=") { + if s := SwitchNamed(name); s != nil && s.takesValue() { + i++ + } + } + } + return "" +} + +// Dispatch runs the command named by the first of a mode's arguments. With none it shows +// short help, so a mode that does something by default does it before calling this. +func Dispatch(mode *Mode, config *lib.ConfigSettings, args []string) error { + if len(args) == 0 { + PrintHelp(mode, false) + return nil + } + if strings.EqualFold(args[0], Help) { + PrintHelp(mode, true) + return nil + } + for i := range mode.Commands { + if strings.EqualFold(args[0], mode.Commands[i].Name) { + positional, err := ParseCommandSwitches(mode, args[1:]) + if err != nil { + return err + } + return mode.Commands[i].Run(config, positional) + } + } + return fmt.Errorf("'%s' is not a %s %s command - use '%s %s --help' to see what is available", + args[0], tool.Name, mode.Name, tool.Name, mode.Name) +} + +// ParseCommandSwitches parses switches wherever they appear among a command's arguments, +// and returns the rest. The flag package stops at the first non-switch, which would make +// 'notehub skills get all ./dir --project x' ignore the project. +func ParseCommandSwitches(mode *Mode, args []string) (positional []string, err error) { + + // Separate the switches, and the values they take, from the arguments + found := []string{} + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + positional = append(positional, args[i+1:]...) + break + } + if len(arg) < 2 || arg[0] != '-' { + positional = append(positional, arg) + continue + } + name := strings.TrimPrefix(arg[1:], "-") + if name == "" || name[0] == '-' || name[0] == '=' { + return nil, fmt.Errorf("bad flag syntax: %s", arg) + } + found = append(found, arg) + if !strings.Contains(name, "=") { + if s := SwitchNamed(name); s != nil && s.takesValue() && i+1 < len(args) { + i++ + found = append(found, args[i]) + } + } + } + + // Hold them to the same mode rules as the switches that precede the command + if err = ValidateSwitches(mode, found); err != nil { + return nil, err + } + + // Parsing again adds to what was already parsed, and the configuration switches among + // them, such as --hub, take effect now + if err = flag.CommandLine.Parse(found); err != nil { + return nil, err + } + printRequestedHelp(mode) + if err = lib.ConfigFlagsProcess(); err != nil { + return nil, err + } + if tool.AfterParse != nil { + tool.AfterParse() + } + + return positional, nil + +} + +// printRequestedHelp handles help before a mode or command can perform any work +func printRequestedHelp(mode *Mode) { + if agentHelpGiven { + fmt.Print(tool.AgentHelp) + os.Exit(0) + } + if helpGiven { + PrintHelp(mode, true) + os.Exit(0) + } +} + +// usage is a mode's USAGE line +func usage(mode *Mode) string { + line := tool.Name + if mode.Name != ModeDefault { + line += " " + mode.Name + } + line += " [options]" + if mode.Args != "" { + line += " " + mode.Args + } + return line +} + +// PrintHelp displays help for a mode. The short form, shown when there's nothing to do, +// lists the modes, the mode's commands and the general options; the full form, for --help, +// adds every switch the mode accepts. A tool can keep examples in full help only. +func PrintHelp(mode *Mode, full bool) { + + // Modes are listed only in top-level help, and the default mode isn't one of them + // there: the usage line and the examples show its form + listed := []*Mode{} + if mode.Name == ModeDefault { + for _, m := range VisibleModes() { + if m.Name != ModeDefault { + listed = append(listed, m) + } + } + } + + // Header. The default mode is the tool itself; every other mode is named. + if mode.Name == ModeDefault { + fmt.Printf("%s - %s\n", tool.Name, tool.Title) + fmt.Printf("USAGE: %s\n", usage(mode)) + if len(listed) != 0 { + fmt.Printf(" %s [options] [arguments]\n", tool.Name) + } + } else { + fmt.Printf("%s %s - %s\n", tool.Name, mode.Name, mode.Summary) + fmt.Printf("USAGE: %s\n", usage(mode)) + } + fmt.Println() + + if tool.AgentHelp != "" { + fmt.Printf("IF YOU ARE AN AGENT, THIS WILL TELL YOU ALL YOU NEED TO KNOW:\n %s --help-agent\n\n", tool.Name) + } + + // What the mode wishes to say before anything else + if mode.Preamble != "" { + fmt.Printf("%s\n\n", strings.TrimRight(mode.Preamble, "\n")) + } + + // The switches to show; the short form shows only the general ones + shown := []*Switch{} + for _, s := range switches { + if s.Hidden || !s.AllowedIn(mode.Name) || (!full && s.Group != GroupGeneral) { + continue + } + shown = append(shown, s) + } + + // Align against the longest label + maxLen := 0 + measure := func(label string) { + if len(label) > maxLen { + maxLen = len(label) + } + } + for _, s := range shown { + measure(s.displayName()) + } + for _, c := range mode.Commands { + measure(CommandLabel(c)) + } + for _, m := range listed { + measure(m.Name) + } + padding := maxLen + 5 + + // The modes + if len(listed) != 0 { + fmt.Printf("Modes:\n") + for _, m := range listed { + fmt.Printf(" %*s%s\n", -(padding + 2), m.Name, m.Summary) + } + fmt.Println() + } + + // This mode's commands + if len(mode.Commands) != 0 { + fmt.Printf("Commands:\n") + for _, c := range mode.Commands { + fmt.Printf(" %*s%s\n", -(padding + 2), CommandLabel(c), c.Summary) + } + fmt.Println() + } + + // This mode's switches, in group order + for _, group := range tool.Groups { + printedGroup := false + for _, s := range shown { + if s.Group != group.Name { + continue + } + if !printedGroup { + fmt.Printf("%s:\n", group.Description) + printedGroup = true + } + _, usage := s.unquoteUsage() + fmt.Printf(" --%*s%s\n", -padding, s.displayName(), usage) + } + if printedGroup { + fmt.Println() + } + } + + // How the mode is used, in gh's form: what each example does, then the command line + if len(mode.Examples) != 0 && (full || !tool.OmitShortHelpExamples) { + fmt.Printf("Examples:\n") + for i, example := range mode.Examples { + if i != 0 { + fmt.Println() + } + fmt.Printf(" # %s\n %s\n", example.What, example.Command) + } + fmt.Println() + } + + // Anything else this mode wishes to say + if full && mode.Detail != "" { + fmt.Printf("%s\n\n", strings.TrimRight(mode.Detail, "\n")) + } + + fmt.Println("For more detailed documentation and examples, visit:") + fmt.Println(tool.DocsURL) + +} + +// PrintSettings shows the saved settings this tool uses, after its short help +func PrintSettings(config *lib.ConfigSettings) { + config.PrintSettings(tool.NotecardConfig, tool.NotehubConfig) +} + +// runHelp is the handler for ' help [mode]' +func runHelp(config *lib.ConfigSettings) error { + + // With no argument, display the top-level help + args := flag.Args() + if len(args) == 0 { + PrintHelp(ModeNamed(ModeDefault), true) + return nil + } + + // Display help for the specified mode + mode := ModeNamed(args[0]) + if mode == nil { + return fmt.Errorf("there is no '%s' mode - the modes are: %s", args[0], strings.Join(ModeNames(), ", ")) + } + PrintHelp(mode, true) + return nil + +} + +// CheckNotAMode refuses a bare word where a mode's argument is expected. A mode's +// arguments are requests or commands, so a bare word is nearly always a mode keyword that +// was misspelled or misplaced. +func CheckNotAMode(arg string) error { + if !IsBareWord(arg) { + return nil + } + if mode := ModeNamed(arg); mode != nil { + return fmt.Errorf("'%s' is a mode, and a mode must be the first thing on the command line, as in: %s", + arg, usage(mode)) + } + found, usages := []string{}, []string{} + for _, mode := range VisibleModes() { + for _, command := range mode.Commands { + if strings.EqualFold(arg, command.Name) { + found = append(found, mode.Name) + usages = append(usages, tool.Name+" "+mode.Name+" "+CommandLabel(command)) + } + } + } + if len(found) == 1 { + return fmt.Errorf("'%s' is a command of the %s mode, as in: %s", arg, found[0], usages[0]) + } + if len(found) > 1 { + return fmt.Errorf("'%s' is a command of the %s modes, as in: %s", arg, strings.Join(found, " and "), strings.Join(usages, ", or ")) + } + return fmt.Errorf("'%s' is neither a request nor a mode - a request is JSON or @filename, and the modes are: %s", + arg, strings.Join(ModeNames(), ", ")) +} + +// IsBareWord returns true if arg is a single word, without the punctuation of a request, +// a filename or a switch +func IsBareWord(arg string) bool { + if arg == "" { + return false + } + for _, c := range arg { + isLetter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + isDigit := c >= '0' && c <= '9' + if !isLetter && !isDigit && c != '-' && c != '_' { + return false + } + } + return true +} diff --git a/lib/cli/cli_test.go b/lib/cli/cli_test.go new file mode 100644 index 0000000..f5f68d6 --- /dev/null +++ b/lib/cli/cli_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cli + +import ( + "io" + "os" + "slices" + "strings" + "testing" + + "github.com/blues/note-cli/lib" +) + +// The switches of the test tool +var ( + testProject string + testVerbose bool +) + +// testTool is a small tool with a default mode, a mode with commands, and a hidden mode +func testTool() *Tool { + nothing := func(*lib.ConfigSettings) error { return nil } + return &Tool{ + Name: "tool", + Title: "a tool for testing", + Modes: []*Mode{ + {Name: ModeDefault, Summary: "do the usual thing", Args: "[request]", Run: nothing, + Examples: []Example{{"Do the usual thing to a project", "tool --project app:1"}}}, + {Name: "things", Summary: "manage things", Args: "", Run: nothing, + Commands: []Command{{Name: "list", Summary: "list the things", Run: func(*lib.ConfigSettings, []string) error { return nil }}}}, + {Name: "secret", Summary: "a mode that isn't listed", Hidden: true, Run: nothing}, + }, + Switches: []*Switch{ + {Name: "hub", External: true, ValueType: "hub", Group: GroupGeneral, Modes: []string{AnyMode}, Usage: "the hub"}, + {Name: "project", Target: &testProject, Group: "scope", Modes: []string{ModeDefault, "things"}, Usage: "the `projectUID` to act on"}, + {Name: "verbose", Target: &testVerbose, Group: "scope", Modes: []string{ModeDefault}, Usage: "say more"}, + }, + Groups: []Group{{GroupGeneral, "General Options"}, {"scope", "Scope"}}, + } +} + +// Defining a tool adds --help and the help mode, and checks the tables +func TestDefine(t *testing.T) { + Define(testTool()) + if s := Switches()[0]; s.Name != Help || s.Group != GroupGeneral { + t.Errorf("the first switch is %+v, expected --help among the general options", s) + } + if mode := ModeNamed(Help); mode == nil || !mode.Hidden { + t.Errorf("the help mode is %+v, expected a hidden one", mode) + } + if names := ModeNames(); !slices.Equal(names, []string{ModeDefault, "things"}) { + t.Errorf("the visible modes are %v, expected the default and 'things'", names) + } + if Name() != "tool" { + t.Errorf("the tool is named %q", Name()) + } +} + +// A mistake in the tables is a programming error, reported when the tool is defined +func TestDefineRefusesBadTables(t *testing.T) { + bad := testTool() + bad.Switches = append(bad.Switches, &Switch{Name: "lost", Target: &testVerbose, Group: "nowhere", Modes: []string{ModeDefault}}) + bad.Switches = append(bad.Switches, &Switch{Name: "verbose", Target: &testVerbose, Group: "scope", Modes: []string{ModeDefault}}) + defer func() { + message, _ := recover().(string) + for _, expected := range []string{"--lost is in group 'nowhere'", "--verbose is defined twice for the 'default' mode"} { + if !strings.Contains(message, expected) { + t.Errorf("defining a bad tool reported %q, expected it to say %q", message, expected) + } + } + Define(testTool()) + }() + Define(bad) + t.Errorf("defining a bad tool didn't panic") +} + +// The first argument names a mode, unless it is a switch or a request +func TestExtractMode(t *testing.T) { + Define(testTool()) + for _, test := range []struct { + args []string + mode string + remaining []string + }{ + {[]string{"things", "list"}, "things", []string{"list"}}, + {[]string{"--things", "list"}, "things", []string{"list"}}, + {[]string{"THINGS"}, "things", []string{}}, + {[]string{"secret"}, "secret", []string{}}, + {[]string{"--help"}, ModeDefault, []string{"--help"}}, + {[]string{"--project", "app:1"}, ModeDefault, []string{"--project", "app:1"}}, + {[]string{`{"req":"hub.app.get"}`}, ModeDefault, []string{`{"req":"hub.app.get"}`}}, + {[]string{}, ModeDefault, []string{}}, + } { + mode, remaining := ExtractMode(test.args) + if mode.Name != test.mode || !slices.Equal(remaining, test.remaining) { + t.Errorf("%v: got mode '%s' and %v, expected '%s' and %v", test.args, mode.Name, remaining, test.mode, test.remaining) + } + } +} + +// A switch used in the wrong mode is named as such, with where it may be used +func TestValidateSwitches(t *testing.T) { + Define(testTool()) + things := ModeNamed("things") + if err := ValidateSwitches(things, []string{"--project", "app:1", "list"}); err != nil { + t.Errorf("--project is available in 'things', but: %s", err) + } + err := ValidateSwitches(things, []string{"--verbose", "list"}) + if err == nil || !strings.Contains(err.Error(), "not available in 'things' mode (it is available in: default)") { + t.Errorf("--verbose isn't available in 'things', but: %v", err) + } + if err := ValidateSwitches(things, []string{"--unknown"}); err != nil { + t.Errorf("an unknown switch is the flag package's to report, but: %s", err) + } +} + +// Short help shows the modes, the general options and the examples; full help adds every +// switch of the mode and its detail +func TestPrintHelp(t *testing.T) { + Define(testTool()) + short := captureStdout(t, func() { PrintHelp(ModeNamed(ModeDefault), false) }) + for _, expected := range []string{ + "tool - a tool for testing\n", "USAGE: tool [options] [request]\n", "tool [options] [arguments]\n", + "Modes:\n", " things", "General Options:\n", "--help", "--hub (hub)", "Examples:\n # Do the usual thing to a project\n tool --project app:1\n", + "https://dev.blues.io/tools-and-sdks/tool-cli\n", + } { + if !strings.Contains(short, expected) { + t.Errorf("short help lacks %q:\n%s", expected, short) + } + } + for _, unexpected := range []string{"secret", "--project (projectUID)", "Scope:"} { + if strings.Contains(short, unexpected) { + t.Errorf("short help shows %q:\n%s", unexpected, short) + } + } + full := captureStdout(t, func() { PrintHelp(ModeNamed(ModeDefault), true) }) + for _, expected := range []string{"Scope:\n", "--project (projectUID)", "the projectUID to act on", "--verbose "} { + if !strings.Contains(full, expected) { + t.Errorf("full help lacks %q:\n%s", expected, full) + } + } + things := captureStdout(t, func() { PrintHelp(ModeNamed("things"), true) }) + for _, expected := range []string{"tool things - manage things\n", "USAGE: tool things [options] \n", "Commands:\n", " list", "--project (projectUID)"} { + if !strings.Contains(things, expected) { + t.Errorf("mode help lacks %q:\n%s", expected, things) + } + } + if strings.Contains(things, "Modes:") || strings.Contains(things, "--verbose") { + t.Errorf("mode help lists the modes or a switch of another mode:\n%s", things) + } +} + +// A bare word where a request goes is a mode or a command out of place +func TestCheckNotAMode(t *testing.T) { + Define(testTool()) + for word, expected := range map[string]string{ + "things": "is a mode, and a mode must be the first thing on the command line, as in: tool things [options] ", + "list": "is a command of the things mode, as in: tool things list", + "other": "is neither a request nor a mode", + } { + err := CheckNotAMode(word) + if err == nil || !strings.Contains(err.Error(), expected) { + t.Errorf("'%s': got %v, expected it to say %q", word, err, expected) + } + } + if err := CheckNotAMode(`{"req":"hub.app.get"}`); err != nil { + t.Errorf("a request is not a bare word, but: %s", err) + } +} + +// captureStdout returns what fn writes to stdout +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + saved := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + fn() + w.Close() + os.Stdout = saved + out, _ := io.ReadAll(r) + return string(out) +} diff --git a/lib/config-http.go b/lib/config-http.go new file mode 100644 index 0000000..fa6671e --- /dev/null +++ b/lib/config-http.go @@ -0,0 +1,51 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the copyright holder. + +package lib + +import ( + "context" + "fmt" + "net/http" + + "github.com/blues/note-go/notehub" +) + +// ConfigDoAuthenticated is the common request path for Notehub operations. +// It renews expiring credentials before sending and retries a 401 once when a +// refresh succeeds and the request body can be replayed. +func ConfigDoAuthenticated(client *http.Client, req *http.Request) (*http.Response, error) { + config, err := GetConfig() + if err != nil { + return nil, err + } + return config.doAuthenticated(client, req, config.Hub) +} + +func (config *ConfigSettings) doAuthenticated(client *http.Client, req *http.Request, hub string) (*http.Response, error) { + return notehub.DoAuthenticatedRequest(client, req, func(ctx context.Context, rejectedToken string) (string, error) { + if _, err := config.RefreshCredentialsForHub(ctx, hub, rejectedToken); err != nil { + return "", err + } + credentials := config.CredentialsForHub(hub) + if credentials == nil { + return "", fmt.Errorf("not logged in to %s: set %s, or run 'notehub login'", hub, ConfigTokenEnvVar) + } + return credentials.Token, nil + }) +} + +// IntrospectCredentials checks the current credentials with Notehub using the +// same renewal and retry policy as data operations. Explicit PAT validation and +// OAuth exchanges keep using their supplied token, avoiding recursive refresh. +func (config *ConfigSettings) IntrospectCredentials(ctx context.Context, hub string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL(hub)+"/userinfo", nil) + if err != nil { + return "", err + } + response, err := config.doAuthenticated(http.DefaultClient, req, hub) + if err != nil { + return "", err + } + return introspectionResponse(response) +} diff --git a/lib/config-http_test.go b/lib/config-http_test.go new file mode 100644 index 0000000..bdc86f4 --- /dev/null +++ b/lib/config-http_test.go @@ -0,0 +1,118 @@ +package lib + +import ( + "context" + "errors" + "io" + "net/http" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/blues/note-go/notehub" +) + +// Data requests and credential checks share renewal, including persisted token +// rotation. This exercises the SDK helper with the CLI's real credential source. +func TestAuthenticatedConfigRequests(t *testing.T) { + for _, path := range []string{"/req", "/v1/projects", "/v1/projects/app:1/files", "/userinfo"} { + t.Run(path, func(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(-time.Hour)) + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + var calls []string + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + calls = append(calls, req.URL.Path) + body := `{"email":"user@example.com"}` + if req.URL.Path == "/oauth2/token" { + body = `{"access_token":"renewed","refresh_token":"rotated","expires_in":3600}` + } else if req.Header.Get("Authorization") != "Bearer renewed" { + t.Error("sent request before refreshing its credentials") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + if path == "/userinfo" { + email, err := settings.IntrospectCredentials(context.Background(), settings.Hub) + if err != nil || email != "user@example.com" { + t.Fatalf("introspection failed: %q, %v", email, err) + } + } else { + req, _ := http.NewRequest("POST", "https://"+settings.Hub+path, strings.NewReader(`{"req":"test"}`)) + response, err := settings.doAuthenticated(http.DefaultClient, req, settings.Hub) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + } + if !slices.Equal(calls, []string{"/oauth2/token", path}) { + t.Errorf("calls=%v", calls) + } + stored, err := readConfigFromFile() + if err != nil || stored.SavedCredentials(settings.Hub).RefreshToken != "rotated" { + t.Fatalf("rotation was not saved: %v", err) + } + }) + } +} + +func TestAuthenticatedConfigEarlyRefreshFailure(t *testing.T) { + for _, rejected := range []bool{false, true} { + t.Run(strconv.FormatBool(rejected), func(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(30*time.Second)) + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + refreshes, requests := 0, 0 + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/oauth2/token" { + refreshes++ + return nil, errors.New("connection reset") + } + requests++ + if req.Header.Get("Authorization") != "Bearer old-access" { + t.Error("lost the still-valid access token") + } + status := 200 + if rejected { + status = 401 + } + return &http.Response{StatusCode: status, Body: http.NoBody}, nil + }) + req, _ := http.NewRequest("GET", "https://"+settings.Hub+"/v1/projects", nil) + resp, err := settings.doAuthenticated(http.DefaultClient, req, settings.Hub) + if resp != nil { + resp.Body.Close() + } + wantRefreshes := 1 + if rejected { + wantRefreshes = 2 + } + if (err != nil) != rejected || requests != 1 || refreshes != wantRefreshes { + t.Fatalf("requests=%d refreshes=%d err=%v", requests, refreshes, err) + } + if errors.Is(err, notehub.ErrRefreshTokenRejected) { + t.Fatal("network failure reported as a rejected grant") + } + }) + } +} + +func TestAuthenticatedConfig401(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(time.Hour)) + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + var calls []string + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + calls = append(calls, req.URL.Path) + code, body := 401, `{"err":"deleted sign-in"}` + if req.URL.Path == "/oauth2/token" { + code, body = 200, `{"access_token":"renewed","refresh_token":"rotated","expires_in":3600}` + } + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + _, err := settings.IntrospectCredentials(context.Background(), settings.Hub) + if err == nil || !slices.Equal(calls, []string{"/userinfo", "/oauth2/token", "/userinfo"}) { + t.Fatalf("revocation was not respected after one renewal: calls=%v, err=%v", calls, err) + } +} diff --git a/lib/config-lock-unix.go b/lib/config-lock-unix.go new file mode 100644 index 0000000..4bda4b4 --- /dev/null +++ b/lib/config-lock-unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package lib + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func tryConfigLock(file *os.File) (bool, error) { + err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return false, nil + } + return err == nil, err +} diff --git a/lib/config-lock-windows.go b/lib/config-lock-windows.go new file mode 100644 index 0000000..d1ba902 --- /dev/null +++ b/lib/config-lock-windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package lib + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryConfigLock(file *os.File) (bool, error) { + err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} diff --git a/lib/config-refresh.go b/lib/config-refresh.go new file mode 100644 index 0000000..71ed03e --- /dev/null +++ b/lib/config-refresh.go @@ -0,0 +1,73 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the copyright holder. + +package lib + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/blues/note-go/notehub" +) + +const credentialRefreshMargin = time.Minute + +// ConfigRefreshCredentials silently renews the selected hub's saved OAuth login. +// A rejectedToken forces renewal after a 401, unless another CLI already replaced +// that token. The result says whether the caller should retry with new credentials. +func ConfigRefreshCredentials(ctx context.Context, rejectedToken string) (bool, error) { + config, err := GetConfig() + if err != nil { + return false, err + } + return config.RefreshCredentials(ctx, rejectedToken) +} + +func (config *ConfigSettings) RefreshCredentials(ctx context.Context, rejectedToken string) (bool, error) { + return config.RefreshCredentialsForHub(ctx, config.Hub, rejectedToken) +} + +// RefreshCredentialsForHub also supports status --all without changing the active +// connection or applying its environment override to other saved connections. +func (config *ConfigSettings) RefreshCredentialsForHub(ctx context.Context, hub, rejectedToken string) (bool, error) { + credentials := config.CredentialsForHub(hub) + if credentials == nil || credentials.Source != "" || !credentials.IsOAuthAccessToken() || credentials.RefreshToken == "" { + return false, nil + } + needsRefresh := func(creds *ConfigCreds) bool { + return creds.ExpiresAt == nil || !creds.ExpiresAt.After(time.Now().Add(credentialRefreshMargin)) + } + if rejectedToken == "" && !needsRefresh(credentials) { + return false, nil + } + changed := false + err := config.withStoredCredentials(ctx, func(stored *ConfigSettings) (bool, error) { + current := stored.SavedCredentials(hub) + if current == nil { + return false, fmt.Errorf("not logged in to %s; run 'notehub login'", hub) + } + changed = current.Token != credentials.Token || (rejectedToken != "" && current.Token != rejectedToken) + if !current.IsOAuthAccessToken() || current.RefreshToken == "" || (!needsRefresh(current) && (rejectedToken == "" || current.Token != rejectedToken)) { + return false, nil + } + token, err := notehub.RefreshAccessToken(ctx, hub, current.RefreshToken) + if errors.Is(err, notehub.ErrRefreshTokenRejected) { + return false, fmt.Errorf("%s: %w; run 'notehub login' to authorize a new session", hub, err) + } + if err != nil { + // Early renewal is opportunistic while this access token is still + // usable. Never hide rejection, cancellation, or a failure after expiry. + if ctx.Err() == nil && rejectedToken != current.Token && current.ExpiresAt != nil && current.ExpiresAt.After(time.Now()) { + return false, nil + } + return false, fmt.Errorf("refreshing login for %s: %w", hub, err) + } + current.Token, current.RefreshToken, current.ExpiresAt = token.AccessToken, token.RefreshToken, &token.ExpiresAt + stored.HubCreds[hub] = *current + changed = true + return true, nil + }) + return changed, err +} diff --git a/lib/config-refresh_test.go b/lib/config-refresh_test.go new file mode 100644 index 0000000..15180c4 --- /dev/null +++ b/lib/config-refresh_test.go @@ -0,0 +1,326 @@ +package lib + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/blues/note-go/notehub" +) + +type refreshTransport func(*http.Request) (*http.Response, error) + +func (fn refreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +func refreshTestConfig(t *testing.T, expires time.Time) *ConfigSettings { + t.Helper() + oldPath, oldHub := configSettingsPath, configSavedHub + t.Cleanup(func() { configSettingsPath, configSavedHub = oldPath, oldHub }) + path := filepath.Join(t.TempDir(), "config.json") + configSettingsPath = func() string { return path } + configSavedHub = "api.other.example" + t.Setenv(ConfigTokenEnvVar, "") + settings := &ConfigSettings{Hub: "api.example.com", HubCreds: map[string]ConfigCreds{ + "api.other.example": {User: "other@example.com", Token: "api_key_other"}, + }} + settings.SetOAuthCredentials(¬ehub.AccessToken{Email: "user@example.com", AccessToken: "old-access", RefreshToken: "old-refresh", ExpiresAt: expires}) + if err := settings.Write(); err != nil { + t.Fatal(err) + } + return settings +} + +func TestCredentialRefresh(t *testing.T) { + for _, test := range []struct { + name string + expires time.Duration + rejected string + env string + legacy bool + status int + body string + wantCalls int + wantErr bool + }{ + {"expired", -time.Hour, "", "", false, 200, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`, 1, false}, + {"nearly expired", 30 * time.Second, "", "", false, 200, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`, 1, false}, + {"fresh", time.Hour, "", "", false, 0, "", 0, false}, + {"401", time.Hour, "old-access", "", false, 200, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`, 1, false}, + {"environment", -time.Hour, "", "api_key_env", false, 0, "", 0, false}, + {"legacy", -time.Hour, "old-access", "", true, 0, "", 0, false}, + {"revoked", -time.Hour, "", "", false, 400, `{"error":"invalid_grant"}`, 1, true}, + {"outage", -time.Hour, "", "", false, 503, `unavailable`, 1, true}, + {"early outage", 30 * time.Second, "", "", false, 503, `unavailable`, 1, false}, + {"early network failure", 30 * time.Second, "", "", false, 0, "", 1, false}, + {"expired network failure", -time.Hour, "", "", false, 0, "", 1, true}, + {"401 during outage", 30 * time.Second, "old-access", "", false, 503, `unavailable`, 1, true}, + {"early revoked", 30 * time.Second, "", "", false, 400, `{"error":"invalid_grant"}`, 1, true}, + } { + t.Run(test.name, func(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(test.expires)) + if test.legacy { + settings.SetDefaultCredentials("old-access", "user@example.com", settings.DefaultCredentials().ExpiresAt) + if err := settings.Write(); err != nil { + t.Fatal(err) + } + } + t.Setenv(ConfigTokenEnvVar, test.env) + before, _ := os.ReadFile(configSettingsPath()) + calls := 0 + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + calls++ + if req.URL.String() != "https://example.com/oauth2/token" { + t.Errorf("wrong issuer: %s", req.URL) + } + if err := req.ParseForm(); err != nil { + t.Fatal(err) + } + if req.Form.Get("refresh_token") != "old-refresh" { + t.Error("wrong refresh token") + } + if test.status == 0 { + return nil, errors.New("connection reset") + } + return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.body)), Header: http.Header{}}, nil + }) + changed, err := settings.RefreshCredentials(context.Background(), test.rejected) + if (err != nil) != test.wantErr || calls != test.wantCalls || changed != (test.wantCalls == 1 && test.status == 200 && !test.wantErr) { + t.Fatalf("changed=%v calls=%d err=%v", changed, calls, err) + } + if test.status == 400 && !errors.Is(err, notehub.ErrRefreshTokenRejected) { + t.Fatalf("lost rejection: %v", err) + } + after, _ := os.ReadFile(configSettingsPath()) + if !changed { + if !bytes.Equal(before, after) { + t.Error("changed credentials without a successful refresh") + } + return + } + stored, err := readConfigFromFile() + if err != nil { + t.Fatal(err) + } + creds := stored.SavedCredentials(settings.Hub) + if creds.Token != "new-access" || creds.RefreshToken != "new-refresh" || creds.User != "user@example.com" || time.Until(*creds.ExpiresAt) < 59*time.Minute { + t.Error("did not preserve the rotated token pair and identity") + } + if stored.Hub != "api.other.example" || stored.SavedCredentials("api.other.example").Token != "api_key_other" { + t.Error("changed the saved hub or another login") + } + }) + } +} + +func TestRefreshReusesOtherProcessCredentials(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(-time.Hour)) + stale, err := readConfigFromFile() + if err != nil { + t.Fatal(err) + } + stale.Hub = settings.Hub + later := time.Now().Add(time.Hour) + settings.SetOAuthCredentials(¬ehub.AccessToken{Email: "user@example.com", AccessToken: "new-access", RefreshToken: "new-refresh", ExpiresAt: later}) + if err := settings.Write(); err != nil { + t.Fatal(err) + } + changed, err := stale.RefreshCredentials(context.Background(), "old-access") + if err != nil || !changed || stale.DefaultCredentials().Token != "new-access" { + t.Fatalf("did not reuse newer credentials: %v", err) + } + // A stale settings write cannot restore the old pair, nor an already removed login. + settings.HubCreds[settings.Hub] = ConfigCreds{Token: "old-access", User: "user@example.com"} + if err := settings.Write(); err != nil { + t.Fatal(err) + } + stored, _ := readConfigFromFile() + if stored.SavedCredentials(settings.Hub).RefreshToken != "new-refresh" { + t.Fatal("stale settings write restored an old token") + } + delete(stored.HubCreds, settings.Hub) + if err := stored.withStoredCredentials(context.Background(), func(current *ConfigSettings) (bool, error) { delete(current.HubCreds, settings.Hub); return true, nil }); err != nil { + t.Fatal(err) + } + if _, err := stale.RefreshCredentials(context.Background(), "new-access"); err == nil { + t.Fatal("resurrected a removed login") + } +} + +func TestRefreshLockCancellation(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(-time.Hour)) + err := withConfigLock(context.Background(), func() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err := settings.RefreshCredentials(ctx, "") + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected lock cancellation: %v", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestLogoutRevokesRefreshToken(t *testing.T) { + for _, tc := range []struct{ hub, issuer string }{ + {"api.notefile.net", "notehub.io"}, + {"api.example.com", "example.com"}, + } { + t.Run(tc.hub, func(t *testing.T) { + settings := refreshTestConfig(t, time.Now().Add(time.Hour)) + settings.Hub = tc.hub + settings.SetOAuthCredentials(¬ehub.AccessToken{Email: "user@example.com", AccessToken: "old-access", RefreshToken: "old-refresh", ExpiresAt: time.Now().Add(time.Hour)}) + if err := settings.Write(); err != nil { + t.Fatal(err) + } + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + revoked := map[string]string{} + deleted := false + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + status := http.StatusOK + if req.URL.Path == "/auth/logout" { + if req.URL.Host != tc.hub { + t.Errorf("sign-in deletion went to %s, want %s", req.URL.Host, tc.hub) + } + deleted = req.Header.Get("Authorization") == "Bearer old-access" + status = http.StatusNoContent + } else if req.URL.Path == "/oauth2/revoke" { + if req.URL.Host != tc.issuer { + t.Errorf("revocation went to %s, want %s", req.URL.Host, tc.issuer) + } + if err := req.ParseForm(); err != nil { + t.Fatal(err) + } + revoked[req.Form.Get("token_type_hint")] = req.Form.Get("token") + } else { + t.Error("unexpected logout request") + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil + }) + if err := settings.RemoveDefaultCredentials(); err != nil { + t.Fatal(err) + } + stored, err := readConfigFromFile() + if err != nil { + t.Fatal(err) + } + if !deleted || revoked["access_token"] != "old-access" || revoked["refresh_token"] != "old-refresh" { + t.Error("logout did not end the entire OAuth grant") + } + if settings.DefaultCredentials() != nil || stored.SavedCredentials(settings.Hub) != nil || stored.SavedCredentials("api.other.example") == nil { + t.Error("logout did not remove only the selected login") + } + }) + } +} + +// This subprocess helper loads the old pair before the parent releases either CLI. +func TestConfigRefreshProcess(t *testing.T) { + path := os.Getenv("NOTECLI_REFRESH_TEST_PATH") + if path == "" { + return + } + configSettingsPath = func() string { return path } + settings, err := readConfigFromFile() + if err != nil { + t.Fatal(err) + } + settings.Hub = "api.example.com" + endpoint, err := url.Parse(os.Getenv("NOTECLI_REFRESH_TEST_URL")) + if err != nil { + t.Fatal(err) + } + original := http.DefaultTransport + http.DefaultTransport = refreshTransport(func(req *http.Request) (*http.Response, error) { + copy := req.Clone(req.Context()) + copy.URL.Scheme, copy.URL.Host = endpoint.Scheme, endpoint.Host + return original.RoundTrip(copy) + }) + fmt.Println("ready") + if _, err := bufio.NewReader(os.Stdin).ReadString('\n'); err != nil { + t.Fatal(err) + } + if _, err := settings.RefreshCredentials(context.Background(), ""); err != nil { + t.Fatal(err) + } + if settings.DefaultCredentials().Token != "new-access" { + t.Fatal("did not load renewed credentials") + } +} + +func TestConcurrentProcessRefresh(t *testing.T) { + refreshTestConfig(t, time.Now().Add(-time.Hour)) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if calls.Add(1) != 1 { + t.Error("redeemed a rotating token more than once") + } + time.Sleep(100 * time.Millisecond) + io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`) + })) + defer server.Close() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + type child struct { + cmd *exec.Cmd + stdin io.WriteCloser + output *bufio.Reader + stderr bytes.Buffer + } + children := make([]*child, 2) + for i := range children { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + c := &child{cmd: exec.CommandContext(ctx, executable, "-test.run=^TestConfigRefreshProcess$")} + c.cmd.Env = append(os.Environ(), "NOTECLI_REFRESH_TEST_PATH="+configSettingsPath(), "NOTECLI_REFRESH_TEST_URL="+server.URL) + c.cmd.Stderr = &c.stderr + c.stdin, err = c.cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := c.cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + c.output = bufio.NewReader(stdout) + if err := c.cmd.Start(); err != nil { + t.Fatal(err) + } + if ready, err := c.output.ReadString('\n'); err != nil || ready != "ready\n" { + t.Fatalf("child not ready: %q %v", ready, err) + } + children[i] = c + } + for _, c := range children { + io.WriteString(c.stdin, "go\n") + c.stdin.Close() + } + for _, c := range children { + output, _ := io.ReadAll(c.output) + if err := c.cmd.Wait(); err != nil { + t.Fatalf("child failed: %v\n%s\n%s", err, output, c.stderr.String()) + } + } + if calls.Load() != 1 { + t.Fatalf("expected one renewal, got %d", calls.Load()) + } +} diff --git a/lib/config-storage.go b/lib/config-storage.go new file mode 100644 index 0000000..4ae0c6e --- /dev/null +++ b/lib/config-storage.go @@ -0,0 +1,96 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the copyright holder. + +package lib + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/blues/note-go/note" +) + +var configSettingsPath = defaultConfigSettingsPath + +// withConfigLock serializes credentials across processes. The operating system +// releases the lock when the process exits, including after a crash. +func withConfigLock(ctx context.Context, fn func() error) error { + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + file, err := os.OpenFile(configSettingsPath()+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("locking configuration: %w", err) + } + defer file.Close() + for { + locked, err := tryConfigLock(file) + if err != nil { + return fmt.Errorf("locking configuration: %w", err) + } + if locked { + return fn() + } + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for configuration lock: %w", ctx.Err()) + case <-time.After(25 * time.Millisecond): + } + } +} + +// writeConfigJSON replaces a complete file atomically, so readers never see half +// of a rotated token pair. The caller holds the configuration lock. +func writeConfigJSON(contents []byte) error { + path := configSettingsPath() + file, err := os.CreateTemp(filepath.Dir(path), ".config-*") + if err != nil { + return fmt.Errorf("saving configuration: %w", err) + } + defer os.Remove(file.Name()) + if _, err = file.Write(contents); err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(file.Name(), path) + } + if err != nil { + return fmt.Errorf("saving configuration: %w", err) + } + return nil +} + +// withStoredCredentials rereads the latest saved state while locked and commits +// only the credential change. Session-only --hub and other flags aren't saved. +func (config *ConfigSettings) withStoredCredentials(ctx context.Context, update func(*ConfigSettings) (bool, error)) error { + return withConfigLock(ctx, func() error { + stored, err := readConfigFromFile() + if err != nil { + return err + } + if stored == nil { + return fmt.Errorf("saved configuration is missing; run 'notehub login'") + } + changed, err := update(stored) + if err != nil { + return err + } + if changed { + contents, err := note.JSONMarshalIndent(stored, "", " ") + if err != nil { + return err + } + if err := writeConfigJSON(contents); err != nil { + return err + } + } + config.HubCreds = stored.HubCreds + return nil + }) +} diff --git a/lib/config-unix.go b/lib/config-unix.go index 83bf2b3..d143911 100644 --- a/lib/config-unix.go +++ b/lib/config-unix.go @@ -24,6 +24,6 @@ func ConfigDir() string { } // Get the pathname of config settings -func configSettingsPath() string { +func defaultConfigSettingsPath() string { return ConfigDir() + "/config.json" } diff --git a/lib/config-windows.go b/lib/config-windows.go index 06319e6..39980a1 100644 --- a/lib/config-windows.go +++ b/lib/config-windows.go @@ -24,6 +24,6 @@ func ConfigDir() string { } // Get the pathname of config settings -func configSettingsPath() string { +func defaultConfigSettingsPath() string { return ConfigDir() + "\\config.json" } diff --git a/lib/config.go b/lib/config.go index 808921b..1ea0464 100644 --- a/lib/config.go +++ b/lib/config.go @@ -5,13 +5,14 @@ package lib import ( + "context" "errors" "flag" "fmt" "io" - "math/rand" "net/http" "os" + "sort" "strconv" "strings" "time" @@ -23,10 +24,13 @@ import ( // ConfigCreds are the credentials for a given Notehub type ConfigCreds struct { - User string `json:"user,omitempty"` - Token string `json:"token,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - Hub string `json:"-"` + User string `json:"user,omitempty"` + Token string `json:"token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Hub string `json:"-"` + // Source is the environment variable the token came from, or "" for a saved sign-in + Source string `json:"-"` } func (creds ConfigCreds) IsOAuthAccessToken() bool { @@ -39,10 +43,35 @@ func (creds ConfigCreds) IsOAuthAccessToken() bool { return true } +// ExpiredAt reports whether the credentials had expired by the given time +func (creds ConfigCreds) ExpiredAt(now time.Time) bool { + return creds.ExpiresAt != nil && !creds.ExpiresAt.After(now) +} + +// ConfigTimeFormat is how a time is displayed, as in "expires at 2026-01-02 15:04:05 MST" +const ConfigTimeFormat = "2006-01-02 15:04:05 MST" + +// ExpirationDescription says when the credentials expire, as "expires at