settings: three tiers - wikitool.json (instance), .wikitool-local.json (checkout), secrets by reference incl. 1Password CLI; new command config set (#207)
CI / verify (push) Successful in 8m5s
CI / pwsh (push) Successful in 2m34s
Release / release (push) Successful in 36s
Release / wiki-mirror (push) Skipped

Every settings field declares its tier; a value in the wrong file is an error naming
file, key and tier. A field naming a program or affecting a gate cannot be declared
instance. Secrets are references only: {"ref": "<name>"} via WIKITOOL_SECRET_<NAME> or
a user-only secrets file, {"ref": "op://..."} via op read (locked 1Password: exit 42).
The preflight records op as an optional tool. doctor gains one line per tier, docs
verify checks a committed wikitool.json for plaintext secrets, CI resolves a real
op:// value through 1password/load-secrets-action. No feature reads its settings
this way yet; without the two files every other command is unchanged.

Files changed:
- .gitea/workflows/ci.yml
- .gitignore
- AGENTS.md
- CHANGES.md
- INSTALL.md
- VERSION
- docs/explanation/README.md
- docs/explanation/configuration-tiers.md
- docs/how-to/README.md
- docs/how-to/use-1password-for-settings.md
- docs/reference/commands/README.md
- docs/reference/commands/configuration.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/types-instructions-and-docs.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/dev/doc-pull-through.md
- instructions/dev/testing-conventions.md
- instructions/preflight.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/config_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/prerequisites.py
- tools/chemenu/settings.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_settings.py
- tools/chemenu/toolpaths.py
- tools/preflight.ps1
- tools/preflight.sh
- tools/prerequisites.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-10 15:34:28 +02:00
1 parent 059a0a1840
commit f6aaadc413
40 files changed
+2246 -31

No files matched your search

+35 -2
View File
@@ -82,12 +82,14 @@ jobs:
- name: System dependencies - name: System dependencies
# `nodejs` is not for us - it is what act_runner needs to execute the # `nodejs` is not for us - it is what act_runner needs to execute the
# JavaScript action in the next step. It has to be installed before the # JavaScript action in the next step. It has to be installed before the
# checkout, which is why this step comes first. # checkout, which is why this step comes first. `curl` and `unzip` are
# for 1Password's action further down, which fetches its own CLI - the
# set proven by torben/gitea-mcp@ci-binford, binford-release.yaml.
run: | run: |
set -eu set -eu
apt-get update -qq apt-get update -qq
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
python3 python3-venv git nodejs ripgrep ca-certificates python3 python3-venv git nodejs ripgrep ca-certificates curl unzip
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -145,6 +147,37 @@ jobs:
.venv/bin/python -m pytest -q \ .venv/bin/python -m pytest -q \
--cov --cov-report=term --cov-report=xml --cov-report=html --cov --cov-report=term --cov-report=xml --cov-report=html
- name: Load a real secret reference from 1Password
# The CI half of the tiered settings (tools/chemenu/settings.py). Of the
# two ways a configuration reaches 1Password in CI - `op` itself with a
# service-account token, or an action resolving the `op://` references
# into variables first - this is the second, the shape this Gitea
# instance already runs (torben/gitea-mcp@ci-binford,
# binford-release.yaml). A `{"ref": "ci_smoke"}` then resolves through
# `WIKITOOL_SECRET_CI_SMOKE`. The token is a user-level secret whose
# service account sees only the CI-CD vault; the field read is a
# username, the least sensitive value that vault holds, and the action
# masks it in the log all the same. A pull request from a fork gets no
# secrets, so this step fails there - nobody but the operator pushes here.
uses: 1password/load-secrets-action@v2
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
WIKITOOL_SECRET_CI_SMOKE: op://CI-CD/gitea-package-token/username
- name: Secret references resolve in CI
# `CHEMENU_LIVE_SECRET_REQUIRE=1` turns "no value loaded" into a failure:
# without it the test skips and stays green, which is the outcome this
# step exists to rule out - the same reason the CalDAV step below sets
# `CHEMENU_LIVE_REQUIRE`.
env:
CHEMENU_LIVE_SECRET_REQUIRE: "1"
run: |
set -eu
cd tools
.venv/bin/python -m pytest -q chemenu/tests/test_settings.py -k live_secret
- name: Start Radicale - name: Start Radicale
# Its own step because the script hands the server's address to the suite through # Its own step because the script hands the server's address to the suite through
# $GITHUB_ENV, which only reaches the steps after the one that wrote it. # $GITHUB_ENV, which only reaches the steps after the one that wrote it.
+6
View File
@@ -153,6 +153,12 @@ npm-debug.log*
# preflight has not run, and `tools/wikitool` refuses to start (exit 42). # preflight has not run, and `tools/wikitool` refuses to start (exit 42).
/.wikitool-tools.json /.wikitool-tools.json
# The checkout tier of the settings (tools/chemenu/settings.py): what is true
# only for this clone on this machine. Its committed counterpart, `wikitool.json`,
# holds the instance tier and must stay tracked - `docs verify` checks both.
# Secrets are in neither file; both hold only references to them.
/.wikitool-local.json
# Live-suite tracker profiles (Gitea #156): one file per tracker of the user's own that the # Live-suite tracker profiles (Gitea #156): one file per tracker of the user's own that the
# live suite may be pointed at (`CHEMENU_LIVE_PROFILE=<name>`, # live suite may be pointed at (`CHEMENU_LIVE_PROFILE=<name>`,
# instructions/dev/tracker-testing.md). They carry the same credentials as the file above # instructions/dev/tracker-testing.md). They carry the same credentials as the file above
+31 -3
View File
@@ -16,6 +16,7 @@ maintained permanently; anything mechanical is done by `tools/wikitool`, never b
- [File naming](#file-naming) - [File naming](#file-naming)
- [Personalization](#personalization) - [Personalization](#personalization)
- [Environment](#environment) - [Environment](#environment)
- [Settings](#settings)
- [Routing](#routing) - [Routing](#routing)
- [Gates](#gates) - [Gates](#gates)
- [Tool error contract](#tool-error-contract) - [Tool error contract](#tool-error-contract)
@@ -153,7 +154,7 @@ type, index, lint or provenance; `dist export` ships it verbatim except `docs/de
verify` checks only that every file sits in one of its five subdirectories, and no other verify` checks only that every file sits in one of its five subdirectories, and no other
`tools/wikitool` command touches it. `tools/wikitool` command touches it.
Six explanation pages are reached from this file, each by link rather than automatically: Seven explanation pages are reached from this file, each by link rather than automatically:
[docs/explanation/pipeline-rationale.md](docs/explanation/pipeline-rationale.md) (why the [docs/explanation/pipeline-rationale.md](docs/explanation/pipeline-rationale.md) (why the
pipeline has four stages), pipeline has four stages),
[docs/explanation/ownership-and-templates.md](docs/explanation/ownership-and-templates.md) (why [docs/explanation/ownership-and-templates.md](docs/explanation/ownership-and-templates.md) (why
@@ -165,10 +166,13 @@ reader rather than the owner),
[docs/explanation/why-gates-are-code.md](docs/explanation/why-gates-are-code.md) (why the [docs/explanation/why-gates-are-code.md](docs/explanation/why-gates-are-code.md) (why the
gates in [Gates](#gates) are code rather than instruction), gates in [Gates](#gates) are code rather than instruction),
[docs/explanation/version-model.md](docs/explanation/version-model.md) (why a version number [docs/explanation/version-model.md](docs/explanation/version-model.md) (why a version number
answers a compatibility question and a migration question separately), and answers a compatibility question and a migration question separately),
[docs/explanation/knowledge-and-commitment.md](docs/explanation/knowledge-and-commitment.md) (why [docs/explanation/knowledge-and-commitment.md](docs/explanation/knowledge-and-commitment.md) (why
commitments live in a task tracker rather than in `kb/`, and why the two are joined at read time commitments live in a task tracker rather than in `kb/`, and why the two are joined at read time
instead of synced). A seventh, `docs/explanation/model-and-effort-selection.md`, is deliberately instead of synced), and
[docs/explanation/configuration-tiers.md](docs/explanation/configuration-tiers.md) (why settings
sit in three tiers, why a value has exactly one place, and what keeping secrets out of the tree
does not protect against). An eighth, `docs/explanation/model-and-effort-selection.md`, is deliberately
not linked here but from `CLAUDE.md`: it decides something only that harness has to decide, and not linked here but from `CLAUDE.md`: it decides something only that harness has to decide, and
a link here would load it into the other three. a link here would load it into the other three.
@@ -210,6 +214,30 @@ It carries no authority: a remote or MCP server listed here does not authorize a
(invariant 5) or open a gate, and does not source a `kb/` claim (invariant 3). It holds no (invariant 5) or open a gate, and does not source a `kb/` claim (invariant 3). It holds no
credentials - it sits in plaintext in the working tree and in every agent's context. credentials - it sits in plaintext in the working tree and in every agent's context.
## Settings
`wikitool` reads its settings from two files at the root: `wikitool.json`, committed, for what
is the same in every clone of the instance, and `.wikitool-local.json`, gitignored, for what is
true only for this checkout. Secrets sit in neither - both hold only a reference to them,
`{"ref": "<name>"}` or `{"ref": "op://<vault>/<item>/<field>"}`. Each field declares which tier
it belongs to, and **a field that names a program to run or affects a gate is never in the
committed file**, not even as a reference: a pull from another clone must not change what
`wikitool` executes or where it pushes. The code refuses to declare such a field otherwise.
- Write a setting with `tools/wikitool config set`, which puts it where its tier says. A field
that names a program or affects a gate is the user's to edit by hand - `config set` exits 42
and says so.
- **A secret never passes through you.** Never ask for one in the chat, never put one on a
command line, and never read the secrets file or run `op read` to look at one. Ask the user to
put the value into 1Password, the secrets file or a `WIKITOOL_SECRET_<NAME>` variable, then set
the reference.
- `tools/wikitool doctor` reports whether both files pass and whether each reference resolves -
never a value.
Like `ENVIRONMENT.md`, neither file sources a `kb/` claim (invariant 3). What keeping secrets
out of the tree does and does not protect against:
[docs/explanation/configuration-tiers.md](docs/explanation/configuration-tiers.md).
## Routing ## Routing
**The pipeline** - four stages, each with one job: **The pipeline** - four stages, each with one job:
+8 -1
View File
@@ -14,16 +14,23 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
--- ---
## 9.0.1-beta.2 - 2026-10-10 - raw_cmd.py in ein Paket zerlegt: ein Modul je Unterkommando, Ausgabe unverändert ## 9.1.0-beta.1 - 2026-10-10 - Einstellungen in drei Stufen: wikitool.json (Instanz), .wikitool-local.json (Checkout), Secrets nur per Verweis - auch über die 1Password-CLI; neues Kommando config set
**Author:** Torben Nehmer **Author:** Torben Nehmer
<!-- wikitool:bumps --> <!-- wikitool:bumps -->
**Medium impact**
- Einstellungen in drei Stufen: wikitool.json (Instanz), .wikitool-local.json (Checkout), Secrets nur per Verweis - auch über die 1Password-CLI; neues Kommando config set
**Low impact** **Low impact**
- Ketten von Paketen: stack-dev schreibt ## Kette, stack-close nennt das nächste Glied - Ketten von Paketen: stack-dev schreibt ## Kette, stack-close nennt das nächste Glied
- raw_cmd.py in ein Paket zerlegt: ein Modul je Unterkommando, Ausgabe unverändert - raw_cmd.py in ein Paket zerlegt: ein Modul je Unterkommando, Ausgabe unverändert
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
### Einstellungen in drei Stufen, Secrets nur per Verweis (#207)
Neue Einstellungen liegen in `wikitool.json` (eingecheckt, gleich in jedem Klon) oder `.wikitool-local.json` (gitignoriert, nur dieser Checkout); jedes Feld deklariert seine Stufe, ein Wert in der falschen Datei ist ein Fehler, keine Überschreibung. Ein Feld, das ein Programm benennt oder ein Gate betrifft, ist nie `instance`. Secrets stehen nur als Verweis in den Dateien: `{"ref": "<name>"}` über `WIKITOOL_SECRET_<NAME>` oder eine Secret-Datei im Nutzerverzeichnis, `{"ref": "op://..."}` über die 1Password-CLI, die der Preflight als optionales Werkzeug erfasst; gesperrtes 1Password ergibt exit 42. `config set` schreibt dorthin, wo die Stufe es verlangt, und verweigert Secret-Werte; `doctor` zeigt eine Zeile pro Stufe, `docs verify` prüft `wikitool.json` auf Klartext-Secrets. Noch liest kein Feature seine Einstellungen so.
--- ---
## 9.0.0 - 2026-10-09 - Docs sorted into four areas, generated references, raw derive for real documents ## 9.0.0 - 2026-10-09 - Docs sorted into four areas, generated references, raw derive for real documents
+4
View File
@@ -29,6 +29,10 @@ preflight names the install command for your system. The list is generated from
- **ripgrep (rg)** - **ripgrep (rg)**
<!-- /wikitool:prerequisites --> <!-- /wikitool:prerequisites -->
Optional: the **1Password CLI (`op`)**, if you want secrets and private values in the settings to
come from 1Password ([use-1password-for-settings.md](docs/how-to/use-1password-for-settings.md)).
The preflight records it when it finds it and never asks for it.
You also need: You also need:
- **An agent harness**: Claude Code, GitHub Copilot (in VS Code or as a CLI), Codex CLI or - **An agent harness**: Claude Code, GitHub Copilot (in VS Code or as a CLI), Codex CLI or
+1 -1
View File
@@ -1 +1 @@
9.0.1-beta.2 9.1.0-beta.1
+2
View File
@@ -18,5 +18,7 @@ lives in `AGENTS.md` or a `CONTRACT.md`, and a page here only gives its reasons.
and a migration question separately and a migration question separately
- [knowledge-and-commitment.md](knowledge-and-commitment.md) - why commitments live in a task - [knowledge-and-commitment.md](knowledge-and-commitment.md) - why commitments live in a task
tracker rather than in `kb/` tracker rather than in `kb/`
- [configuration-tiers.md](configuration-tiers.md) - why settings sit in three tiers, and what
keeping secrets out of the tree does not protect against
- [model-and-effort-selection.md](model-and-effort-selection.md) - which model and effort suit - [model-and-effort-selection.md](model-and-effort-selection.md) - which model and effort suit
which kind of session, for the harness that has to choose which kind of session, for the harness that has to choose
+85
View File
@@ -0,0 +1,85 @@
# Configuration tiers
Settings are split into three tiers: what belongs to the instance (`wikitool.json`, committed),
what belongs to one checkout (`.wikitool-local.json`, gitignored), and secrets, which sit
outside the working tree and are reached only by reference. The files, their fields and the
command that writes them are in [the configuration reference](../reference/configuration.md#tiered-settings);
the rules an agent follows are in [AGENTS.md § Settings](../../AGENTS.md#settings). This page is
about why it is built this way, and what it does not do.
## Three kinds of data that used to share files
Before the tiers, every feature kept its settings in a gitignored file of its own. Three kinds of
data ended up mixed in them:
- **What belongs to the instance.** The weekly review's thresholds are a decision about the
corpus, and every clone of it should make the same one. Gitignoring them meant each clone had
to be told again.
- **What belongs to one checkout.** A tool path, the push URLs a checkout may publish to, the
consent to record telemetry on this disk. Committing any of these would hand every other clone
an answer that is wrong for it rather than missing.
- **Secrets.** A tracker token sat in plain text in the working tree, protected only by
`.gitignore` - one wrong pattern away from a commit, and in plain view of every agent that read
the file in the course of ordinary work.
The tiers give each kind one place, declared per field, so the question "where does this go" is
answered once, by whoever declares the field, rather than each time by whoever writes a value.
## One place per value, not an override
A familiar design lets the local file override the committed one. It is deliberately not this
one: a value in the wrong file is an error. With two possible sources for one value, every
reader first has to work out which one wins, and the answer differs between clones - a reviewer
looking at `wikitool.json` would no longer know what the instance actually runs with. One place
per value keeps the committed file a true statement about every clone.
## What runs, and where it pushes, never travels with a pull
A field that names a program to run, or that arms or loosens a gate, is never in the committed
file - not even as a reference, since a resolved reference is still a value someone else chose.
Otherwise a `git pull` from another clone could change what `wikitool` executes on this machine,
or where `publish` sends this checkout's content. The rule is checked when a field is declared,
in code, so no reader has to remember it; the reasoning is the same as for the gates
([why-gates-are-code.md](why-gates-are-code.md)). The same fields are also not written by
`config set`: like the Publish-Remote Gate's allowlist, they change by the user's own edit.
## References instead of values
A secret is only ever a reference, and a private value - a host name, an internal URL - may be
one. A reference names where the value lives: a name, which resolves through a
`WIKITOOL_SECRET_<NAME>` variable or a secrets file in the user's own configuration directory, or
an `op://` URI, which resolves through the 1Password CLI.
That is what makes the committed file portable. An instance that is public commits
`wikitool.json` publicly, so a private value can never sit there as text; as an `op://`
reference it can, and every machine signed in to the same 1Password account resolves it to the
same value. In CI, `op` runs with a service-account token and resolves the same `op://`
references. Alternatively, 1Password's own action resolves values into variables before
`wikitool` starts, which serves references by name. This repository's CI does the second.
A reference is resolved only when a command asks for that one value, never when the files are
read, and the value stays in that process's memory. `doctor` reports whether each reference
resolves, never what it resolves to.
## What this does not protect against
Keeping secrets out of the tree protects against three things: a secret being committed, a
secret sitting in a file an agent reads while doing ordinary work, and a secret appearing in
`wikitool`'s output.
It does not protect against an agent with shell access that sets out to read one. If 1Password
is unlocked in the terminal, the agent can run `op read` itself. It can read the secrets file. In
general, whatever the `wikitool` process can read in a user account, an agent running in that same
account can read too. The stack does not claim otherwise.
What limits the exposure without preventing it:
- a 1Password vault of its own for this instance's entries, so an unlocked `op` reaches nothing
else;
- the approval prompt of the 1Password desktop app, which asks a human before each new process
reads a value;
- in CI, a service account that sees only that vault.
The rule in [AGENTS.md § Settings](../../AGENTS.md#settings) - a secret never passes through the
agent - is an instruction, not a barrier. The tiers make following it the easy path. They do not
make breaking it impossible.
+2
View File
@@ -25,5 +25,7 @@ Running the instance:
- [Set up the MCP read server](set-up-the-mcp-server.md) - for a consumer that is not a terminal - [Set up the MCP read server](set-up-the-mcp-server.md) - for a consumer that is not a terminal
on this machine on this machine
- [Trace and score agent sessions](trace-and-score-sessions.md) - [Trace and score agent sessions](trace-and-score-sessions.md)
- [Use 1Password for settings](use-1password-for-settings.md) - secrets and private values out
of the tree, on the desktop and in CI
Installing a new instance is [INSTALL.md](../../INSTALL.md) at the repository root. Installing a new instance is [INSTALL.md](../../INSTALL.md) at the repository root.
+56
View File
@@ -0,0 +1,56 @@
# Use 1Password for settings
Keep secrets and private values (host names, internal URLs) out of the working tree by storing
them in 1Password and leaving only an `op://` reference in the settings. The committed
`wikitool.json` then works on every machine signed in to the same 1Password account, and in CI.
What the files and references are: [configuration reference](../reference/configuration.md#tiered-settings).
Why, and what this does not protect against: [configuration-tiers.md](../explanation/configuration-tiers.md).
## On a desktop
1. **Install the 1Password CLI** (`op`) and turn on its integration with the 1Password desktop
app, so `op` asks the app instead of keeping a session of its own. 1Password's own
documentation describes both steps for each platform.
2. **Run the preflight again** - `tools/preflight.sh`, or in PowerShell 7
`pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1`. It records `op` in
`.wikitool-tools.json` and shows it as `OK`; without `op` the line reads `ABSENT`, which is no
problem until a reference needs it.
3. **Make a vault of its own for this instance**, and put each secret or private value into an
item there. A vault of its own limits what an unlocked `op` can reach.
4. **Copy each value's reference** (`op://<vault>/<item>/<field>`) - the 1Password app offers it
on the field - and tell the agent which setting it belongs to:
> Set `<section>.<field>` to the reference `op://<vault>/<item>/<field>`.
The agent runs `tools/wikitool config set <section>.<field> --ref op://<vault>/<item>/<field>`,
which writes the reference into `wikitool.json`. Never paste the value itself into the chat:
the agent should never see it.
5. **Check it:** `tools/wikitool doctor`. The `settings-secrets` line says how many references
resolve. When the desktop app asks to approve access, that is the prompt doing its job - a
locked app makes the line a `WARN`, and a command that needs the value stops with exit 42 until
you unlock it.
## Without 1Password
A reference by name works the same way without `op`:
- set the reference: `tools/wikitool config set <section>.<field> --ref <name>`;
- put the value into `WIKITOOL_SECRET_<NAME>` (the name upper-cased) in the environment, or into
the secrets file `~/.config/chemenu/<instance_id>/secrets.json` as `{"<name>": "<value>"}`,
readable by you only (`chmod 600`). The `instance_id` is in `wikitool.json`.
## In CI
There are two ways, and which one fits depends on the form of the references:
- **`op` itself, with a service-account token** - for settings that hold `op://` references.
Install `op` in the job, run the preflight so it records it, and set `OP_SERVICE_ACCOUNT_TOKEN`;
the references then resolve exactly as on a desktop.
- **1Password's action resolves the values first** - for settings that hold references by name.
`1password/load-secrets-action` with a service-account token reads an `op://` reference into a
variable before `wikitool` starts; name the variable `WIKITOOL_SECRET_<NAME>` and use
`{"ref": "<name>"}` in the settings. This repository's own CI does this, in
`.gitea/workflows/ci.yml`. An `op://` reference in the settings is not resolved this way: it
still needs `op`.
Give the service account access to the instance's own vault only.
+7
View File
@@ -21,6 +21,7 @@ collected in [exit codes and gates](../exit-codes.md).
- [Telemetry](#telemetry) - [Telemetry](#telemetry)
- [Distribution and versioning](#distribution-and-versioning) - [Distribution and versioning](#distribution-and-versioning)
- [Content migrations](#content-migrations) - [Content migrations](#content-migrations)
- [Configuration](#configuration)
- [Instance health](#instance-health) - [Instance health](#instance-health)
<!-- /wikitool:toc --> <!-- /wikitool:toc -->
@@ -157,6 +158,12 @@ Full records: [content-migrations.md](content-migrations.md)
- [`migrate done`](content-migrations.md#migrate-done) - Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`. - [`migrate done`](content-migrations.md#migrate-done) - Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`.
- [`migrate baseline`](content-migrations.md#migrate-baseline) - Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. - [`migrate baseline`](content-migrations.md#migrate-baseline) - Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.
## Configuration
Full records: [configuration.md](configuration.md)
- [`config set`](configuration.md#config-set) - Set one value of the tiered settings, in the file its tier names.
## Instance health ## Instance health
Full records: [instance-health.md](instance-health.md) Full records: [instance-health.md](instance-health.md)
+67
View File
@@ -0,0 +1,67 @@
# Configuration
Writing the tiered settings in `wikitool.json` and `.wikitool-local.json`.
<!-- wikitool:reference-commands -->
## `config set`
Set one value of the tiered settings, in the file its tier names.
**Synopsis**
- `wikitool config set <section>.<field> <value>`
- `wikitool config set <section>.<field> --ref <name|op://vault/item/field>`
**Options**
```text
Arguments:
key <section>.<field> [required]
value The plain value; a string as given, anything else as JSON.
Options:
--ref <str> A reference instead of a value: <name> or
op://<vault>/<item>/<field>.
-h, --help Show this message and exit.
```
**Examples**
```bash
tools/wikitool config set tracker.stale_days 14
tools/wikitool config set tracker.token --ref tracker_token
tools/wikitool config set tracker.url --ref op://Chemenu/tracker/url
```
**Behaviour**
- Writes a plain value of a field of tier `instance` into `wikitool.json`, and one of tier `checkout` - a private field's included - into `.wikitool-local.json`.
- With `--ref`, writes a reference instead of a value into `wikitool.json`: `<name>` resolves through `WIKITOOL_SECRET_<NAME>`, then the secrets file in the user's configuration directory; `op://<vault>/<item>/<field>` resolves through the 1Password CLI. Only a secret or private field takes one.
- Removes the same key from the other file, so a value never has two places, and says so.
- Creates `wikitool.json` with a fresh `instance_id` the first time it writes anything; the secrets file is found by that id.
- Checks both files as they would stand after the write against every registered section, and writes nothing when that fails.
- Prints the key and the file it wrote, never the value. Resolves no reference.
**Properties**
- Changes files: yes
- Idempotent: yes
- Atomic: Each file is replaced in one rename; both are checked before either is written
- Counts toward the session call budget: yes
- Can reach the network: no
**Exit codes**
- `0` - success
- `1` - The key names no registered section or field, or is not `<section>.<field>`
- `1` - A plain value given for a field of tier `secret`
- `1` - `--ref` given for a field that takes a plain value, or a malformed reference
- `1` - The value is not of the field's type, or the files would not pass their check afterwards
- `42` - The field names a program to run or affects a gate
**See also**
- `wikitool doctor` - whether both files pass and every reference resolves
- `docs/reference/configuration.md` - the files, the tiers and the references
- `docs/how-to/use-1password-for-settings.md` - setting 1Password up as the source
<!-- /wikitool:reference-commands -->
+2 -1
View File
@@ -39,6 +39,7 @@ tools/wikitool doctor --json
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`. - Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`.
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`. - For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is. - For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
- Settings (`settings-instance`, `settings-checkout`, `settings-secrets`): one line per tier. `wikitool.json` and `.wikitool-local.json` each present or absent - absent is `OK` - and a `FAIL` naming file, key and the right tier when a value sits in the wrong file, a secret or a committed private field holds a plain value, or a value fails its field; a section this version does not read is a `WARN`. For the references: how many resolve, never a value - an unresolvable one is a `FAIL`, one 1Password will not hand out until a human unlocks it a `WARN`.
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback. - Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
- Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`. A `WARN` when `.wikitool-telemetry.json` is not read as written - unreadable JSON, not an object, `enabled` not a JSON boolean, a cap not a positive integer, or an unknown key - naming the field; telemetry itself drops or reinterprets such a value without a word, so this is where it shows. - Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`. A `WARN` when `.wikitool-telemetry.json` is not read as written - unreadable JSON, not an object, `enabled` not a JSON boolean, a cap not a positive integer, or an unknown key - naming the field; telemetry itself drops or reinterprets such a value without a word, so this is where it shows.
- Exits 1 only on a `FAIL`; a missing remote, session id or `VERSION` is a `WARN`, not a fault. - Exits 1 only on a `FAIL`; a missing remote, session id or `VERSION` is a `WARN`, not a fault.
@@ -61,7 +62,7 @@ tools/wikitool doctor --json
- `instructions/setup-instance.md` - the setup steps most findings point back to - `instructions/setup-instance.md` - the setup steps most findings point back to
- `instructions/preflight.md` - what `tool-paths` and `install-dir` point back to - `instructions/preflight.md` - what `tool-paths` and `install-dir` point back to
- `docs/reference/configuration.md` - the per-checkout configuration files - `docs/reference/configuration.md` - the per-checkout configuration files and the tiered settings
- `docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps - `docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps
- `instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID` - `instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`
<!-- /wikitool:reference-commands --> <!-- /wikitool:reference-commands -->
@@ -283,7 +283,8 @@ tools/wikitool docs verify
- Collections: every directory under `kb/` has a `COLLECTION.md` and no directory outside it does; every collection declares `profile:` and a `required_by_stack:` that agrees with the stack's own list. - Collections: every directory under `kb/` has a `COLLECTION.md` and no directory outside it does; every collection declares `profile:` and a `required_by_stack:` that agrees with the stack's own list.
- Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a type-spec declaring `subtype_field:`, names a value that field's schema enum allows, and carries no frontmatter; no pre-migration `type: entity` blocks are left in the contracts. - Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a type-spec declaring `subtype_field:`, names a value that field's schema enum allows, and carries no frontmatter; no pre-migration `type: entity` blocks are left in the contracts.
- `kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; every stage contract is present. - `kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; every stage contract is present.
- The `.gitignore` canaries clear in both directions: nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories. - The `.gitignore` canaries clear in both directions: nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories, `.wikitool-local.json` ignored and `wikitool.json` tracked.
- A `wikitool.json` at the root holds every value in the place its tier allows - no secret and no private field as plain text, no `checkout` field at all except as a reference - and a well-formed `schema` and `instance_id`. The local file is not read.
- No `.md`/`.template` file `dist export` would ship cites an issue number. A `<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's text, from which it is already gone. - No `.md`/`.template` file `dist export` would ship cites an issue number. A `<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's text, from which it is already gone.
- Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check. - Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check.
- Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference. - Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference.
@@ -312,6 +313,7 @@ tools/wikitool docs verify
- `1` - The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale - `1` - The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale
- `1` - A type-spec's own frontmatter fails its schema - `1` - A type-spec's own frontmatter fails its schema
- `1` - A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter - `1` - A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter
- `1` - `wikitool.json` holds a secret or private value as plain text, or a value its tier puts elsewhere
- `1` - A shipped `.md`/`.template` cites an issue number - `1` - A shipped `.md`/`.template` cites an issue number
- `1` - A region of the human reference under `docs/reference/` is stale - `1` - A region of the human reference under `docs/reference/` is stale
- `1` - A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group - `1` - A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group
+66 -3
View File
@@ -1,7 +1,7 @@
# Configuration # Configuration
The environment variables `wikitool` reads, and the per-checkout files an operator may create at the The environment variables `wikitool` reads, the per-checkout files an operator may create at the
root of an instance. None of them is needed for an instance to work: an instance installed from a root of an instance, and the two tiered settings files. None of them is needed for an instance to work: an instance installed from a
release runs against the origin's public release feed with no configuration at all. release runs against the origin's public release feed with no configuration at all.
The shape of each file - its fields, what an absent or a malformed file means, an example - is The shape of each file - its fields, what an absent or a malformed file means, an example - is
@@ -24,6 +24,7 @@ something you run.
- [Super Productivity](#super-productivity) - [Super Productivity](#super-productivity)
- [CalDAV](#caldav) - [CalDAV](#caldav)
- [MCP submissions](#mcp-submissions) - [MCP submissions](#mcp-submissions)
- [Tiered settings](#tiered-settings)
<!-- /wikitool:toc --> <!-- /wikitool:toc -->
## Environment variables ## Environment variables
@@ -35,6 +36,7 @@ something you run.
| `WIKITOOL_UPDATE_URL` | The release feed `version check`, `version notes` and `dist upgrade --latest` ask | The value in `.wikitool-release.json`, else the origin's feed | | `WIKITOOL_UPDATE_URL` | The release feed `version check`, `version notes` and `dist upgrade --latest` ask | The value in `.wikitool-release.json`, else the origin's feed |
| `WIKITOOL_UPDATE_TOKEN` | A Gitea token for the release feed, sent only to downloads on the feed's own host | None. Not needed against the origin, only against a feed in a private repository | | `WIKITOOL_UPDATE_TOKEN` | A Gitea token for the release feed, sent only to downloads on the feed's own host | None. Not needed against the origin, only against a feed in a private repository |
| `WIKITOOL_TASKS_CONFIG` | Path to a tracker configuration that `task`, `review` and `doctor` read instead of `.wikitool-tasks.json`, for running one checkout against several trackers in turn | `.wikitool-tasks.json` at the root. A path to a file that does not exist is an error, never "no tracker configured" | | `WIKITOOL_TASKS_CONFIG` | Path to a tracker configuration that `task`, `review` and `doctor` read instead of `.wikitool-tasks.json`, for running one checkout against several trackers in turn | `.wikitool-tasks.json` at the root. A path to a file that does not exist is an error, never "no tracker configured" |
| `WIKITOOL_SECRET_<NAME>` | The value behind a settings reference `{"ref": "<name>"}`, for CI and containers - `<NAME>` is the name upper-cased | The secrets file in the user's configuration directory ([below](#tiered-settings)) |
| `CHEMENU_ROOT` | Which corpus the package works on, for a caller outside the checkout itself | The checkout the package sits in. `tools/wikitool` behaves the same without it | | `CHEMENU_ROOT` | Which corpus the package works on, for a caller outside the checkout itself | The checkout the package sits in. `tools/wikitool` behaves the same without it |
| `WIKI_TRACE` | `0`/`1` turns telemetry off or on, beating both the default and `.wikitool-telemetry.json` | The installation-form default ([below](#telemetry)) | | `WIKI_TRACE` | `0`/`1` turns telemetry off or on, beating both the default and `.wikitool-telemetry.json` | The installation-form default ([below](#telemetry)) |
| `WIKI_TRACE_DIR` | Writes traces somewhere other than `reports/telemetry/` | `reports/telemetry/` | | `WIKI_TRACE_DIR` | Writes traces somewhere other than `reports/telemetry/` | `reports/telemetry/` |
@@ -84,7 +86,10 @@ both caps. What the caps do and what never reaches a trace:
## Tool paths ## Tool paths
**The preflight writes this file, not you.** `.wikitool-tools.json` at the root holds the absolute **The preflight writes this file, not you.** `.wikitool-tools.json` at the root holds the absolute
paths of Python, git and ripgrep, as the preflight found them on this machine. `wikitool` starts git paths of Python, git and ripgrep, as the preflight found them on this machine - and of the
1Password CLI `op` when it is installed. `op` is optional: the preflight records it when it finds
it, reports `ABSENT` when it does not, and never asks for it. Only an `op://` reference in the
[tiered settings](#tiered-settings) needs it. `wikitool` starts git
and rg from there instead of from `PATH`. It is per checkout and gitignored, because a path on one and rg from there instead of from `PATH`. It is per checkout and gitignored, because a path on one
machine says nothing about the next. machine says nothing about the next.
@@ -330,3 +335,61 @@ Example:
<!-- /wikitool:reference-config-upload --> <!-- /wikitool:reference-config-upload -->
Size and quota are your deployment's decision, not defaults worth copying blindly. Size and quota are your deployment's decision, not defaults worth copying blindly.
## Tiered settings
Two files at the root hold the settings of the features that read them this way, and every field
declares the tier it belongs to:
| Tier | File | Holds |
|------|------|-------|
| `instance` | `wikitool.json`, committed | What belongs to the corpus and is the same in every clone |
| `checkout` | `.wikitool-local.json`, gitignored | What is true only for this clone on this machine |
| `secret` | neither | Tokens and passwords - both files hold only a reference |
Each file is a JSON object of sections, one per feature, beside two keys of its own:
```json
{
"schema": 1,
"instance_id": "6f1c0e52-3d1b-4b8e-9a51-2f0e7d1c9b44",
"<section>": {"<field>": "<value>"}
}
```
`instance_id` sits in `wikitool.json` only. The first `wikitool config set` creates the file and
the id; the id names the directory of the secrets file below. No feature reads its settings this
way yet, so this version declares no section: `config set` names the sections it knows, and
`doctor` reports a section it does not read as a `WARN`.
**One place per value.** A value in the file its tier does not name is an error naming the file,
the key and the right tier - never an override. The same holds for a key set in both files.
**References.** A field of tier `secret` takes only a reference; a field marked private - a host
name, an internal URL - takes a plain value in `.wikitool-local.json` or a reference in either
file. No other field takes one.
| Reference | Resolves through |
|-----------|------------------|
| `{"ref": "<name>"}` - lower-case letters, digits and single `_` | `WIKITOOL_SECRET_<NAME>`, else the key `<name>` in the secrets file |
| `{"ref": "op://<vault>/<item>/<field>"}` | `op read`, the 1Password CLI the preflight recorded |
The secrets file is `~/.config/chemenu/<instance_id>/secrets.json` (`$XDG_CONFIG_HOME` instead of
`~/.config` when it is set; `%APPDATA%\chemenu\<instance_id>\secrets.json` on Windows), a JSON object of
names to strings. Outside Windows it must be readable by its owner only (`chmod 600`); a file
others can read is refused.
A reference is resolved when a command asks for that value, never when the files are read. A name
nobody provides, an `op` that is not installed, or an item 1Password does not have ends the command
with exit 1; a 1Password that is locked, signed out or waiting for an approval ends it with exit 42,
for a human to unlock. Setting 1Password up: [use-1password-for-settings.md](../how-to/use-1password-for-settings.md).
**Never in the committed file:** a field that names a program to run or affects a gate - not even
as a reference. `config set` does not write such a field either; the user edits
`.wikitool-local.json` by hand, and `doctor` checks it afterwards.
`wikitool doctor` reports one line per tier - `settings-instance`, `settings-checkout`,
`settings-secrets` - and for the references only whether they resolve. `docs verify` holds a
committed `wikitool.json` to the placement rules above, a plain secret or private value above
all. Neither file ships with `dist export`. Why the tiers exist, and what keeping secrets out of
the tree does not protect against: [configuration-tiers.md](../explanation/configuration-tiers.md).
+1
View File
@@ -38,4 +38,5 @@ What each exit code means, and how a caller reacts to it, is [AGENTS.md § Tool
- [`publish`](commands/git.md#publish): Rebase-Review Gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff - [`publish`](commands/git.md#publish): Rebase-Review Gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff
- [`publish`](commands/git.md#publish): Publish-Remote Gate: `.wikitool-remotes.json` exists and the push URL of `--remote` is not listed in it, or `--remote` resolves to no push URL - [`publish`](commands/git.md#publish): Publish-Remote Gate: `.wikitool-remotes.json` exists and the push URL of `--remote` is not listed in it, or `--remote` resolves to no push URL
- [`export guidelines`](commands/guideline-export.md#export-guidelines): Guideline Push Gate: `--push` without `--confirm`, or with a token that does not match the current set of targets - [`export guidelines`](commands/guideline-export.md#export-guidelines): Guideline Push Gate: `--push` without `--confirm`, or with a token that does not match the current set of targets
- [`config set`](commands/configuration.md#config-set): The field names a program to run or affects a gate
<!-- /wikitool:reference-exit-codes --> <!-- /wikitool:reference-exit-codes -->
+2 -2
View File
@@ -37,10 +37,10 @@ touched; a row that does not apply needs no action.
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` | | A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) | | A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `tools/README.md`, `INSTALL.md` or the pages under `docs/tutorials/`, `docs/how-to/` and `docs/reference/` names it - AGENTS.md § File naming says which document is for which reader. A new page goes into the area its kind of documentation belongs to; that area's `README.md` says which | | A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `tools/README.md`, `INSTALL.md` or the pages under `docs/tutorials/`, `docs/how-to/` and `docs/reference/` names it - AGENTS.md § File naming says which document is for which reader. A new page goes into the area its kind of documentation belongs to; that area's `README.md` says which |
| The reasoning behind a gate, boundary, or design decision | The `docs/explanation/` page that carries it, if one exists (AGENTS.md § File naming lists all six reached from AGENTS.md itself, plus a seventh reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/explanation/` and no issue tracker, so a distributed instance gets the mechanism without the why | | The reasoning behind a gate, boundary, or design decision | The `docs/explanation/` page that carries it, if one exists (AGENTS.md § File naming lists all seven reached from AGENTS.md itself, plus an eighth reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/explanation/` and no issue tracker, so a distributed instance gets the mechanism without the why |
| A task-tracker adapter (`tools/chemenu/tasks/`), its recorded fixtures, or the live suite | [instructions/dev/tracker-testing.md](tracker-testing.md), and `MANIFEST.json` beside the fixtures when they were re-recorded | | A task-tracker adapter (`tools/chemenu/tasks/`), its recorded fixtures, or the live suite | [instructions/dev/tracker-testing.md](tracker-testing.md), and `MANIFEST.json` beside the fixtures when they were re-recorded |
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/`; for a shipped skill (not under `instructions/dev/`) whose name or `description` changed, or one added or removed, `wikitool docs reference --apply` for the skill catalogue `docs/reference/skills.md` | | A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/`; for a shipped skill (not under `instructions/dev/`) whose name or `description` changed, or one added or removed, `wikitool docs reference --apply` for the skill catalogue `docs/reference/skills.md` |
| A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | Its shape - a field, a default, what is required or tolerated - is its record in `tools/chemenu/config_shapes.py`, which the loader reads; change it there, then `wikitool docs reference --apply` for its region in [docs/reference/configuration.md](../../docs/reference/configuration.md). What a setting *does* is the hand-written prose around that region. Also the [setup-instance.md](../setup-instance.md) decision point that offers the file during setup, and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state | | A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`), or a section of the tiered settings (`wikitool.json`, `.wikitool-local.json`; `config_shapes.SETTINGS_SECTIONS`) | Its shape - a field, a default, what is required or tolerated - is its record in `tools/chemenu/config_shapes.py`, which the loader reads; change it there, then `wikitool docs reference --apply` for its region in [docs/reference/configuration.md](../../docs/reference/configuration.md). What a setting *does* is the hand-written prose around that region. Also the [setup-instance.md](../setup-instance.md) decision point that offers the file during setup, and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state |
| An installation instruction - [preflight.md](../preflight.md), [setup-instance.md](../setup-instance.md), [bootstrap.md](../bootstrap.md), [upgrade-instance.md](../upgrade-instance.md) - or `tools/prerequisites.txt` | [INSTALL.md](../../INSTALL.md), the human guide to the same procedure - for `upgrade-instance.md`, [docs/how-to/update-an-instance.md](../../docs/how-to/update-an-instance.md) instead. Read it against the instruction: what to prepare, the sentence for the agent, what the agent asks, where it stops and why. `docs verify` checks only the two enumerable overlaps - the prerequisites lists, which `wikitool docs prerequisites --apply` regenerates from the manifest, and the setup questions: a question the agent asks the user carries `<!-- setup-question: <key> -->` where it is asked in `setup-instance.md`, and `INSTALL.md` § "What the agent asks you" names it with the same marker. Every other sentence is this session's to compare. `INSTALL.md` does not retell the steps, so a change to their order or wording alone moves nothing there | | An installation instruction - [preflight.md](../preflight.md), [setup-instance.md](../setup-instance.md), [bootstrap.md](../bootstrap.md), [upgrade-instance.md](../upgrade-instance.md) - or `tools/prerequisites.txt` | [INSTALL.md](../../INSTALL.md), the human guide to the same procedure - for `upgrade-instance.md`, [docs/how-to/update-an-instance.md](../../docs/how-to/update-an-instance.md) instead. Read it against the instruction: what to prepare, the sentence for the agent, what the agent asks, where it stops and why. `docs verify` checks only the two enumerable overlaps - the prerequisites lists, which `wikitool docs prerequisites --apply` regenerates from the manifest, and the setup questions: a question the agent asks the user carries `<!-- setup-question: <key> -->` where it is asked in `setup-instance.md`, and `INSTALL.md` § "What the agent asks you" names it with the same marker. Every other sentence is this session's to compare. `INSTALL.md` does not retell the steps, so a change to their order or wording alone moves nothing there |
| [dev-setup.md](dev-setup.md), the release workflow (`version bump`/`version release`/`publish`/CI), or another developer-facing process | [docs/dev/development.md](../../docs/dev/development.md), read against it the same way - nothing checks this pair at all. A page under `docs/dev/` is never shipped, so it may link into `instructions/dev/` and cite issue numbers | | [dev-setup.md](dev-setup.md), the release workflow (`version bump`/`version release`/`publish`/CI), or another developer-facing process | [docs/dev/development.md](../../docs/dev/development.md), read against it the same way - nothing checks this pair at all. A page under `docs/dev/` is never shipped, so it may link into `instructions/dev/` and cite issue numbers |
| A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses. A new or changed `root: kb` type-spec or its schema - description, `layout:`, subtype enum, required fields - also needs `wikitool docs reference --apply` for `docs/reference/page-types.md` | | A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses. A new or changed `root: kb` type-spec or its schema - description, `layout:`, subtype enum, required fields - also needs `wikitool docs reference --apply` for `docs/reference/page-types.md` |
+3 -1
View File
@@ -41,6 +41,7 @@ Do not re-do any of this per test; it is done for you, per test, via `monkeypatc
| `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM` | `/dev/null` - git's own way to say "no such file" | | `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM` | `/dev/null` - git's own way to say "no such file" |
| `GIT_DIR`, `GIT_WORK_TREE`, `GIT_AUTHOR_*`, `GIT_COMMITTER_*`, `EMAIL` | unset | | `GIT_DIR`, `GIT_WORK_TREE`, `GIT_AUTHOR_*`, `GIT_COMMITTER_*`, `EMAIL` | unset |
| `WIKI_AUTHOR`, `WIKI_TRACE`, `WIKI_TRACE_CONTENT`, `WIKI_TRACE_MAX_CONTENT`, `WIKITOOL_SESSION_ID`, `WIKITOOL_UPDATE_URL`, `WIKITOOL_UPDATE_TOKEN` | unset | | `WIKI_AUTHOR`, `WIKI_TRACE`, `WIKI_TRACE_CONTENT`, `WIKI_TRACE_MAX_CONTENT`, `WIKITOOL_SESSION_ID`, `WIKITOOL_UPDATE_URL`, `WIKITOOL_UPDATE_TOKEN` | unset |
| every `WIKITOOL_SECRET_*` - a family, cleared by prefix | unset |
`WIKI_TRACE_DIR` is the one variable that stays *set*: the separate `isolated_trace_dir` `WIKI_TRACE_DIR` is the one variable that stays *set*: the separate `isolated_trace_dir`
fixture redirects it into `tmp_path`. Tracing is never disabled suite-wide, because two fixture redirects it into `tmp_path`. Tracing is never disabled suite-wide, because two
@@ -130,7 +131,8 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
fixture creates that machine. fixture creates that machine.
4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in 4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in
`conftest.py` in the same change (`WIKITOOL_TASKS_CONFIG` is one). A variable the tool reads and the fixture does not clear `conftest.py` in the same change (`WIKITOOL_TASKS_CONFIG` is one); a family read by prefix gets
a prefix loop beside it, as `WIKITOOL_SECRET_*` has. A variable the tool reads and the fixture does not clear
is the exact hole this whole file is about, reopened. is the exact hole this whole file is about, reopened.
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call 5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
+3 -1
View File
@@ -19,7 +19,9 @@ Windows. The two answer the same questions from the same list and write the same
(PowerShell only) that the execution policy and the files' Mark of the Web let (PowerShell only) that the execution policy and the files' Mark of the Web let
`tools/wikitool.ps1` start; `tools/wikitool.ps1` start;
- records the absolute path of each tool in `.wikitool-tools.json`, which `wikitool` then starts - records the absolute path of each tool in `.wikitool-tools.json`, which `wikitool` then starts
them from instead of trusting whatever `PATH` a session inherited; them from instead of trusting whatever `PATH` a session inherited - and of an optional tool,
such as the 1Password CLI `op`, when it finds one: an optional tool missing is reported as
`ABSENT` and is never a reason to stop, nor to install anything;
- creates `tools/.venv` from the recorded Python and installs `tools/requirements.txt` into it. - creates `tools/.venv` from the recorded Python and installs `tools/requirements.txt` into it.
<!-- wikitool:toc --> <!-- wikitool:toc -->
+70 -2
View File
@@ -48,6 +48,7 @@ ON FAILURE and NEVER, which address an agent.
- [Telemetry](#telemetry) - [Telemetry](#telemetry)
- [Distribution and versioning](#distribution-and-versioning) - [Distribution and versioning](#distribution-and-versioning)
- [Content migrations](#content-migrations) - [Content migrations](#content-migrations)
- [Configuration](#configuration)
- [Instance health](#instance-health) - [Instance health](#instance-health)
- [Design notes](#design-notes) - [Design notes](#design-notes)
- [Tests](#tests) - [Tests](#tests)
@@ -165,6 +166,7 @@ migrate status read idempotent budget:exempt exit:0,1
migrate verify read idempotent budget:exempt exit:0,1 Compare `kb/` against a git revision on the invariants a content migration must not change. migrate verify read idempotent budget:exempt exit:0,1 Compare `kb/` against a git revision on the invariants a content migration must not change.
migrate done write non-idempotent budget:counted exit:0,1 Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`. migrate done write non-idempotent budget:counted exit:0,1 Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`.
migrate baseline write idempotent budget:counted exit:0,1 Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. migrate baseline write idempotent budget:counted exit:0,1 Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.
config set write idempotent budget:counted exit:0,1,42 Set one value of the tiered settings, in the file its tier names.
doctor read idempotent budget:exempt exit:0,1 Check that this instance is correctly configured. doctor read idempotent budget:exempt exit:0,1 Check that this instance is correctly configured.
``` ```
@@ -2579,6 +2581,7 @@ Check the docs that mirror the code.
- 1 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale - 1 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale
- 1 A type-spec's own frontmatter fails its schema - 1 A type-spec's own frontmatter fails its schema
- 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter - 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter
- 1 `wikitool.json` holds a secret or private value as plain text, or a value its tier puts elsewhere
- 1 A shipped `.md`/`.template` cites an issue number - 1 A shipped `.md`/`.template` cites an issue number
- 1 A region of the human reference under `docs/reference/` is stale - 1 A region of the human reference under `docs/reference/` is stale
- 1 A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group - 1 A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group
@@ -2598,6 +2601,7 @@ Check the docs that mirror the code.
- The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale -> Run `docs contract --apply`, then re-run - The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale -> Run `docs contract --apply`, then re-run
- A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new - A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new
- A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block - A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block
- `wikitool.json` holds a secret or private value as plain text, or a value its tier puts elsewhere -> Show the user the message: a plain value there may already be in the history. Move it as the message says - a secret into 1Password, the secrets file or a variable, set by the user, with only the reference left behind - then re-run
- A shipped `.md`/`.template` cites an issue number -> Say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block - A shipped `.md`/`.template` cites an issue number -> Say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block
- A region of the human reference under `docs/reference/` is stale -> Run `docs reference --apply`, then re-run - A region of the human reference under `docs/reference/` is stale -> Run `docs reference --apply`, then re-run
- A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group -> Create the page with a title, a one-sentence head and the marker pair the message names (or add the pair), then run `docs reference --apply`; `git mv` or `git rm` a page left over from a renamed or removed group - A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group -> Create the page with a title, a one-sentence head and the marker pair the message names (or add the pair), then run `docs reference --apply`; `git mv` or `git rm` a page left over from a renamed or removed group
@@ -2620,7 +2624,8 @@ Check the docs that mirror the code.
- Collections: every directory under `kb/` has a `COLLECTION.md` and no directory outside it does; every collection declares `profile:` and a `required_by_stack:` that agrees with the stack's own list. - Collections: every directory under `kb/` has a `COLLECTION.md` and no directory outside it does; every collection declares `profile:` and a `required_by_stack:` that agrees with the stack's own list.
- Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a type-spec declaring `subtype_field:`, names a value that field's schema enum allows, and carries no frontmatter; no pre-migration `type: entity` blocks are left in the contracts. - Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a type-spec declaring `subtype_field:`, names a value that field's schema enum allows, and carries no frontmatter; no pre-migration `type: entity` blocks are left in the contracts.
- `kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; every stage contract is present. - `kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; every stage contract is present.
- The `.gitignore` canaries clear in both directions: nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories. - The `.gitignore` canaries clear in both directions: nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories, `.wikitool-local.json` ignored and `wikitool.json` tracked.
- A `wikitool.json` at the root holds every value in the place its tier allows - no secret and no private field as plain text, no `checkout` field at all except as a reference - and a well-formed `schema` and `instance_id`. The local file is not read.
- No `.md`/`.template` file `dist export` would ship cites an issue number. A `<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's text, from which it is already gone. - No `.md`/`.template` file `dist export` would ship cites an issue number. A `<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's text, from which it is already gone.
- Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check. - Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check.
- Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference. - Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference.
@@ -3658,6 +3663,68 @@ Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.
- `wikitool migrate done` - advances the version after a migration - `wikitool migrate done` - advances the version after a migration
- `wikitool migrate status` - what is owed from the declared version - `wikitool migrate status` - what is owed from the declared version
### Configuration
#### `config set`
Set one value of the tiered settings, in the file its tier names.
**SYNOPSIS**
- `wikitool config set <section>.<field> <value>`
- `wikitool config set <section>.<field> --ref <name|op://vault/item/field>`
**PROPERTIES**
- effect: write
- idempotent: yes
- atomic: Each file is replaced in one rename; both are checked before either is written
- budget: counted
- network: no
**EXAMPLES**
- `tools/wikitool config set tracker.stale_days 14`
- `tools/wikitool config set tracker.token --ref tracker_token`
- `tools/wikitool config set tracker.url --ref op://Chemenu/tracker/url`
**EXIT STATUS**
- 0 success
- 1 The key names no registered section or field, or is not `<section>.<field>`
- 1 A plain value given for a field of tier `secret`
- 1 `--ref` given for a field that takes a plain value, or a malformed reference
- 1 The value is not of the field's type, or the files would not pass their check afterwards
- 42 The field names a program to run or affects a gate
**ON FAILURE**
- The key names no registered section or field, or is not `<section>.<field>` -> Use a key from the list the message prints; `docs/reference/configuration.md` describes the sections
- A plain value given for a field of tier `secret` -> Do not retry with the value. Ask the user to put it into `WIKITOOL_SECRET_<NAME>`, the secrets file or 1Password, then set the field with `--ref`
- `--ref` given for a field that takes a plain value, or a malformed reference -> Give the value itself, or fix the reference to `<name>` (lower case, digits, `_`) or `op://<vault>/<item>/<field>`
- The value is not of the field's type, or the files would not pass their check afterwards -> Fix the value or the problem the message names in the file it names, then re-run once
- The field names a program to run or affects a gate -> Show the user the output verbatim and stop. Only the user sets this field, by editing `.wikitool-local.json` themselves
**NEVER**
- Never pass a secret's value on the command line - not to this command and not to any other.
- Never read the secrets file or run `op read` yourself to see a value.
**NOTES**
- Writes a plain value of a field of tier `instance` into `wikitool.json`, and one of tier `checkout` - a private field's included - into `.wikitool-local.json`.
- With `--ref`, writes a reference instead of a value into `wikitool.json`: `<name>` resolves through `WIKITOOL_SECRET_<NAME>`, then the secrets file in the user's configuration directory; `op://<vault>/<item>/<field>` resolves through the 1Password CLI. Only a secret or private field takes one.
- Removes the same key from the other file, so a value never has two places, and says so.
- Creates `wikitool.json` with a fresh `instance_id` the first time it writes anything; the secrets file is found by that id.
- Checks both files as they would stand after the write against every registered section, and writes nothing when that fails.
- Prints the key and the file it wrote, never the value. Resolves no reference.
**SEE ALSO**
- `wikitool doctor` - whether both files pass and every reference resolves
- `docs/reference/configuration.md` - the files, the tiers and the references
- `docs/how-to/use-1password-for-settings.md` - setting 1Password up as the source
### Instance health ### Instance health
#### `doctor` #### `doctor`
@@ -3704,6 +3771,7 @@ Check that this instance is correctly configured.
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`. - Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`.
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`. - For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is. - For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
- Settings (`settings-instance`, `settings-checkout`, `settings-secrets`): one line per tier. `wikitool.json` and `.wikitool-local.json` each present or absent - absent is `OK` - and a `FAIL` naming file, key and the right tier when a value sits in the wrong file, a secret or a committed private field holds a plain value, or a value fails its field; a section this version does not read is a `WARN`. For the references: how many resolve, never a value - an unresolvable one is a `FAIL`, one 1Password will not hand out until a human unlocks it a `WARN`.
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback. - Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
- Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`. A `WARN` when `.wikitool-telemetry.json` is not read as written - unreadable JSON, not an object, `enabled` not a JSON boolean, a cap not a positive integer, or an unknown key - naming the field; telemetry itself drops or reinterprets such a value without a word, so this is where it shows. - Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session's count and byte total against both caps; never a `FAIL`. A `WARN` when `.wikitool-telemetry.json` is not read as written - unreadable JSON, not an object, `enabled` not a JSON boolean, a cap not a positive integer, or an unknown key - naming the field; telemetry itself drops or reinterprets such a value without a word, so this is where it shows.
- Exits 1 only on a `FAIL`; a missing remote, session id or `VERSION` is a `WARN`, not a fault. - Exits 1 only on a `FAIL`; a missing remote, session id or `VERSION` is a `WARN`, not a fault.
@@ -3713,7 +3781,7 @@ Check that this instance is correctly configured.
- `instructions/setup-instance.md` - the setup steps most findings point back to - `instructions/setup-instance.md` - the setup steps most findings point back to
- `instructions/preflight.md` - what `tool-paths` and `install-dir` point back to - `instructions/preflight.md` - what `tool-paths` and `install-dir` point back to
- `docs/reference/configuration.md` - the per-checkout configuration files - `docs/reference/configuration.md` - the per-checkout configuration files and the tiered settings
- `docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps - `docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps
- `instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID` - `instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`
<!-- /wikitool:commands --> <!-- /wikitool:commands -->
+6 -2
View File
@@ -54,6 +54,9 @@ the recorded path - or, with no file at all (the test suite, a bare
`python -m chemenu.cli`), falls back to the bare name. A file that is present `python -m chemenu.cli`), falls back to the bare name. A file that is present
but names a path that has gone raises `ToolPathError`, which the CLI turns but names a path that has gone raises `ToolPathError`, which the CLI turns
into an `ERROR` line pointing at the preflight rather than a traceback. into an `ERROR` line pointing at the preflight rather than a traceback.
An optional tool - the 1Password CLI `op`, recorded only when the preflight
found it - is asked for through `toolpaths.optional()`, which answers None
when the file does not name it; the caller says what that costs.
The package runs natively on Windows as well, which CI never does. So the rules that keep The package runs natively on Windows as well, which CI never does. So the rules that keep
it portable are held by reading the source, in the origin repository's `tests/test_portability.py`: a path that it portable are held by reading the source, in the origin repository's `tests/test_portability.py`: a path that
@@ -89,10 +92,11 @@ tools/
cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract
cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index, CONTRACT.md's generated region and the human reference under docs/reference/ render from cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index, CONTRACT.md's generated region and the human reference under docs/reference/ render from
config.py repo layout: root resolution and every path under it config.py repo layout: root resolution and every path under it
config_shapes.py one record per hand-written .wikitool-*.json (telemetry, remotes, upload, tasks): what its loader accepts, its defaults and `Expected:` text, and what docs/reference/configuration.md renders; stdlib-only, since the telemetry policy reads it config_shapes.py one record per hand-written .wikitool-*.json (telemetry, remotes, upload, tasks): what its loader accepts, its defaults and `Expected:` text, and what docs/reference/configuration.md renders; also the sections of the tiered settings, each field with its tier; stdlib-only, since the telemetry policy reads it
settings.py the tiered settings: wikitool.json (instance), .wikitool-local.json (checkout), secrets by reference - placement checked per field, references resolved on access through WIKITOOL_SECRET_<NAME>, the secrets file or `op read`
api.py the in-process entry point - point Chemenu at a corpus and read it api.py the in-process entry point - point Chemenu at a corpus and read it
errors.py ChemenuError / ValidationError / BackendError errors.py ChemenuError / ValidationError / BackendError
toolpaths.py where git and rg are started from: .wikitool-tools.json, bare name only without the file toolpaths.py where git and rg are started from: .wikitool-tools.json, bare name only without the file; an optional tool such as `op` is None when not recorded
filelock.py an exclusive lock on an open file, flock on POSIX and msvcrt on Windows - the only module that imports either filelock.py an exclusive lock on an open file, flock on POSIX and msvcrt on Windows - the only module that imports either
prerequisites.py prerequisites.txt read from Python, plus the platform and long-path questions `doctor` asks prerequisites.py prerequisites.txt read from Python, plus the platform and long-path questions `doctor` asks
install_doc.py INSTALL.md held to the instructions: its prerequisites lists generated from prerequisites.txt, its setup questions matched to setup-instance.md's markers install_doc.py INSTALL.md held to the instructions: its prerequisites lists generated from prerequisites.txt, its setup questions matched to setup-instance.md's markers
+2
View File
@@ -27,6 +27,7 @@ try:
from chemenu.commands import ( from chemenu.commands import (
_util, _util,
cite_cmd, cite_cmd,
config_cmd,
dist_cmd, dist_cmd,
doctor, doctor,
docs_verify, docs_verify,
@@ -180,6 +181,7 @@ app.add_typer(version_cmd.app, name="version")
app.add_typer(migrate_cmd.app, name="migrate") app.add_typer(migrate_cmd.app, name="migrate")
app.add_typer(task_cmd.app, name="task") app.add_typer(task_cmd.app, name="task")
app.add_typer(export_cmd.app, name="export") app.add_typer(export_cmd.app, name="export")
app.add_typer(config_cmd.app, name="config")
app.command("new")(new_page.new_page_command) app.command("new")(new_page.new_page_command)
app.command("touch")(touch_module.touch_command) app.command("touch")(touch_module.touch_command)
app.command("rename")(page_ops.rename_command) app.command("rename")(page_ops.rename_command)
+3
View File
@@ -315,6 +315,9 @@ GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Content migrations", ( ("Content migrations", (
"migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline", "migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline",
)), )),
("Configuration", (
"config set",
)),
("Instance health", ( ("Instance health", (
"doctor", "doctor",
)), )),
+27
View File
@@ -95,6 +95,33 @@ def needs_clearance(msg: str) -> None:
raise typer.Exit(code=EXIT_NEEDS_CLEARANCE) raise typer.Exit(code=EXIT_NEEDS_CLEARANCE)
def load_settings():
"""The tiered settings (`chemenu.settings.load`) for a command, or the
command's exit 1 naming file, key and tier."""
from chemenu import settings
from chemenu.errors import ValidationError
try:
return settings.load()
except ValidationError as exc:
fail(str(exc))
def setting_value(section, name: str) -> Any:
"""One settings value for a command, a reference resolved: exit 1 when it
cannot be found, exit 42 when 1Password needs a human to unlock it first.
Neither message carries the value."""
from chemenu import settings
from chemenu.errors import ValidationError
try:
return section[name]
except settings.SecretSourceLocked as exc:
needs_clearance(str(exc))
except ValidationError as exc:
fail(str(exc))
# A comma preceded by a backslash is a literal comma, not a separator. # A comma preceded by a backslash is a literal comma, not a separator.
_UNESCAPED_COMMA = re.compile(r"(?<!\\),") _UNESCAPED_COMMA = re.compile(r"(?<!\\),")
+201
View File
@@ -0,0 +1,201 @@
"""`wikitool config set` - writes one value of the tiered settings, checked.
The file a value lands in follows from its field's tier (`chemenu.settings`),
never from the caller: a plain `instance` value goes into `wikitool.json`, a
plain `checkout` or private value into `.wikitool-local.json`, and a reference
into `wikitool.json`, where every clone of the instance reads it. Both files are
checked as they would stand after the write, and nothing is written when that
check fails.
Two things it refuses on purpose. A secret's value: whatever runs this command
would see it, and an agent is what runs it most of the time - the value goes
into `WIKITOOL_SECRET_<NAME>`, the secrets file or 1Password, by the user. And a
field that names a program to run or affects a gate: like the Publish-Remote
Gate's allowlist, that one is changed by the user's own edit, never by an agent.
"""
from __future__ import annotations
import json
from typing import Any, Optional
import typer
from chemenu import cli_contract, config, settings
from chemenu.config_shapes import INSTANCE, SECRET, Field
from chemenu.commands._util import fail, needs_clearance, success
from chemenu.errors import ValidationError
app = typer.Typer(help="Write the tiered settings in wikitool.json and .wikitool-local.json.")
def _plain_value(spec: Field, raw: str) -> Any:
"""`raw` as the JSON value `spec` takes: a string as given, anything else
parsed as JSON (`true`, `14`, `["a"]`)."""
if spec.json_type in (None, "string"):
return raw
try:
return json.loads(raw)
except ValueError:
raise ValidationError(f"{spec.name} takes a JSON {spec.json_type}, and the value given is not JSON") from None
@app.command("set")
@cli_contract.record(cli_contract.CommandRecord(
path="config set",
summary="Set one value of the tiered settings, in the file its tier names.",
synopsis=(
cli_contract.Variant(usage="config set <section>.<field> <value>"),
cli_contract.Variant(usage="config set <section>.<field> --ref <name|op://vault/item/field>"),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Each file is replaced in one rename; both are checked before either is written",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Writes a plain value of a field of tier `instance` into `wikitool.json`, and one of tier "
"`checkout` - a private field's included - into `.wikitool-local.json`.",
"With `--ref`, writes a reference instead of a value into `wikitool.json`: `<name>` "
"resolves through `WIKITOOL_SECRET_<NAME>`, then the secrets file in the user's "
"configuration directory; `op://<vault>/<item>/<field>` resolves through the 1Password "
"CLI. Only a secret or private field takes one.",
"Removes the same key from the other file, so a value never has two places, and says so.",
"Creates `wikitool.json` with a fresh `instance_id` the first time it writes anything; "
"the secrets file is found by that id.",
"Checks both files as they would stand after the write against every registered section, "
"and writes nothing when that fails.",
"Prints the key and the file it wrote, never the value. Resolves no reference.",
),
failures=(
cli_contract.Failure(
cause="The key names no registered section or field, or is not `<section>.<field>`",
reaction="Use a key from the list the message prints; `docs/reference/configuration.md` "
"describes the sections",
),
cli_contract.Failure(
cause="A plain value given for a field of tier `secret`",
reaction="Do not retry with the value. Ask the user to put it into "
"`WIKITOOL_SECRET_<NAME>`, the secrets file or 1Password, then set the field with "
"`--ref`",
),
cli_contract.Failure(
cause="`--ref` given for a field that takes a plain value, or a malformed reference",
reaction="Give the value itself, or fix the reference to `<name>` (lower case, digits, "
"`_`) or `op://<vault>/<item>/<field>`",
),
cli_contract.Failure(
cause="The value is not of the field's type, or the files would not pass their check "
"afterwards",
reaction="Fix the value or the problem the message names in the file it names, then "
"re-run once",
),
cli_contract.Failure(
cause="The field names a program to run or affects a gate",
reaction="Show the user the output verbatim and stop. Only the user sets this field, "
"by editing `.wikitool-local.json` themselves",
code=42,
),
),
examples=(
"tools/wikitool config set tracker.stale_days 14",
"tools/wikitool config set tracker.token --ref tracker_token",
"tools/wikitool config set tracker.url --ref op://Chemenu/tracker/url",
),
never=(
"Never pass a secret's value on the command line - not to this command and not to any other.",
"Never read the secrets file or run `op read` yourself to see a value.",
),
see_also=(
"`wikitool doctor` - whether both files pass and every reference resolves",
"`docs/reference/configuration.md` - the files, the tiers and the references",
"`docs/how-to/use-1password-for-settings.md` - setting 1Password up as the source",
),
))
def set_command(
key: str = typer.Argument(..., help="<section>.<field>"),
value: Optional[str] = typer.Argument(None, help="The plain value; a string as given, anything else as JSON."),
ref: Optional[str] = typer.Option(None, "--ref", help="A reference instead of a value: <name> or op://<vault>/<item>/<field>."),
):
"""Set one value of the tiered settings, in the file its tier names."""
if (value is None) == (ref is None):
fail("Give either a value or --ref, not both and not neither")
try:
shape, spec = settings.find_field(key)
except ValidationError as exc:
fail(str(exc))
if spec.user_only:
needs_clearance(
f"{key} names a program to run or affects a gate, so only the user sets it - by "
f"editing {config.LOCAL_SETTINGS_FILENAME} at the root of this checkout themselves. "
"`wikitool doctor` checks the file afterwards."
)
if ref is not None:
if not spec.takes_ref:
fail(f"{key} takes a plain value, not a reference - only a secret or a private field takes one")
stored: Any = {settings.REF_KEY: ref}
try:
settings.parse_ref(stored)
except ValidationError as exc:
fail(f"{key}: {exc}")
in_instance = True
else:
if spec.tier == SECRET:
fail(f"{key} is tier {SECRET!r}: its value never passes through `config set`, where "
"whoever runs the command would see it. Put it into "
f"{config.ENV_SECRET_PREFIX}<NAME>, the secrets file or 1Password, then set "
f"{key} with --ref.")
try:
stored = _plain_value(spec, value)
except ValidationError as exc:
fail(f"{key}: {exc}")
in_instance = spec.tier == INSTANCE
instance_file, local_file = settings.instance_path(), settings.local_path()
try:
instance_data = settings.read_file(instance_file)
local_data = settings.read_file(local_file)
except ValidationError as exc:
fail(str(exc))
instance_data = dict(instance_data or {})
local_data = dict(local_data or {})
instance_data.setdefault("schema", settings.SCHEMA)
created_id = settings.INSTANCE_ID_KEY not in instance_data
instance_data.setdefault(settings.INSTANCE_ID_KEY, settings.new_instance_id())
target, other = (instance_data, local_data) if in_instance else (local_data, instance_data)
target.setdefault("schema", settings.SCHEMA)
target_section = dict(target.get(shape.key) or {})
target_section[spec.name] = stored
target[shape.key] = target_section
removed = False
other_section = other.get(shape.key)
if isinstance(other_section, dict) and spec.name in other_section:
other_section = {k: v for k, v in other_section.items() if k != spec.name}
if other_section:
other[shape.key] = other_section
else:
del other[shape.key]
removed = True
write_local = not in_instance or removed or local_file.exists()
try:
settings.check_files(instance_data, local_data if (write_local or local_data) else None,
complete=False)
except ValidationError as exc:
fail(f"Nothing written - the settings would not pass their check:\n{exc}")
settings.write_file(instance_file, instance_data)
if write_local:
settings.write_file(local_file, local_data)
where = config.INSTANCE_SETTINGS_FILENAME if in_instance else config.LOCAL_SETTINGS_FILENAME
other_name = config.LOCAL_SETTINGS_FILENAME if in_instance else config.INSTANCE_SETTINGS_FILENAME
what = "reference" if ref is not None else "value"
message = f"{key}: {what} set in {where}"
if removed:
message += f"; removed from {other_name}"
if created_id:
message += f" ({config.INSTANCE_SETTINGS_FILENAME} created with a new {settings.INSTANCE_ID_KEY})"
success(message)
+39 -2
View File
@@ -176,6 +176,11 @@ REQUIRED_IGNORE_CANARIES = (
# `raw capture`'s git cache (Gitea #177) - fetched repositories, recomputable, # `raw capture`'s git cache (Gitea #177) - fetched repositories, recomputable,
# and in the way of `publish`'s `git add -A` like the coverage output above. # and in the way of `publish`'s `git add -A` like the coverage output above.
"tools/.wikitool_capture/0123abcd/HEAD", "tools/.wikitool_capture/0123abcd/HEAD",
# The checkout tier of the settings (`chemenu.settings`): true for one clone
# on one machine, so a committed copy would hand every other clone a value
# that is wrong for it. Its counterpart `wikitool.json` is in
# REQUIRED_TRACKED_PATHS below.
".wikitool-local.json",
) )
REQUIRED_TRACKED_PATHS = ( REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md", "reports/CONTRACT.md",
@@ -196,6 +201,10 @@ REQUIRED_TRACKED_PATHS = (
# the directory form would silently drop this file again; this canary is # the directory form would silently drop this file again; this canary is
# what makes that regression fail loudly instead. # what makes that regression fail loudly instead.
"incoming/.gitkeep", "incoming/.gitkeep",
# The instance tier of the settings: the one settings file meant to be
# committed, so every clone of the instance reads the same values. A careless
# `*.json` or `wikitool*` pattern would quietly make it per-checkout.
"wikitool.json",
) )
CLI_README = config.ROOT / "tools" / "CONTRACT.md" CLI_README = config.ROOT / "tools" / "CONTRACT.md"
@@ -1116,13 +1125,30 @@ def check_ignored_content() -> list[str]:
wrongly_ignored = _check_ignore(REQUIRED_TRACKED_PATHS) wrongly_ignored = _check_ignore(REQUIRED_TRACKED_PATHS)
if wrongly_ignored is not None: if wrongly_ignored is not None:
issues += [ issues += [
f"`{path}` is ignored - it must survive the reports/ ignore rule" f"`{path}` is ignored - it must stay tracked, whatever the ignore rules around it exclude"
for path in REQUIRED_TRACKED_PATHS for path in REQUIRED_TRACKED_PATHS
if path in wrongly_ignored if path in wrongly_ignored
] ]
return issues return issues
def check_instance_settings() -> list[str]:
"""A committed `wikitool.json` holds every value where its tier allows:
above all, no secret and no private field as plain text. The file is public wherever the
instance is, so this is the last stop before a value is published - the
local file is not this check's business, it is never committed."""
from chemenu import settings
from chemenu.errors import ValidationError
try:
data = settings.read_file(settings.instance_path())
except ValidationError as exc:
return [str(exc)]
if data is None:
return []
return settings.committed_file_problems(data)
def check_version_changelog() -> list[str]: def check_version_changelog() -> list[str]:
"""`VERSION` must parse, and the newest versioned `CHANGES.md` entry must """`VERSION` must parse, and the newest versioned `CHANGES.md` entry must
name it. name it.
@@ -1334,7 +1360,10 @@ def check_breaking_change_for_boundary() -> list[str]:
"every stage contract is present.", "every stage contract is present.",
"The `.gitignore` canaries clear in both directions: nothing ignored under " "The `.gitignore` canaries clear in both directions: nothing ignored under "
"`raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the " "`raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the "
"published skill directories.", "published skill directories, `.wikitool-local.json` ignored and `wikitool.json` tracked.",
"A `wikitool.json` at the root holds every value in the place its tier allows - no secret "
"and no private field as plain text, no `checkout` field at all except as a reference - "
"and a well-formed `schema` and `instance_id`. The local file is not read.",
"No `.md`/`.template` file `dist export` would ship cites an issue number. A " "No `.md`/`.template` file `dist export` would ship cites an issue number. A "
"`<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's " "`<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's "
"text, from which it is already gone.", "text, from which it is already gone.",
@@ -1405,6 +1434,13 @@ def check_breaking_change_for_boundary() -> list[str]:
reaction="Rename the file to the type and value it was meant for, delete it, or " reaction="Rename the file to the type and value it was meant for, delete it, or "
"remove its frontmatter block", "remove its frontmatter block",
), ),
cli_contract.Failure(
cause="`wikitool.json` holds a secret or private value as plain text, or a value its "
"tier puts elsewhere",
reaction="Show the user the message: a plain value there may already be in the "
"history. Move it as the message says - a secret into 1Password, the secrets file or "
"a variable, set by the user, with only the reference left behind - then re-run",
),
cli_contract.Failure( cli_contract.Failure(
cause="A shipped `.md`/`.template` cites an issue number", cause="A shipped `.md`/`.template` cites an issue number",
reaction="Say what was decided instead of pointing at where, or move the pointer " reaction="Say what was decided instead of pointing at where, or move the pointer "
@@ -1487,6 +1523,7 @@ def verify():
+ check_subtype_templates() + check_subtype_templates()
+ check_legacy_type_blocks() + check_legacy_type_blocks()
+ check_ignored_content() + check_ignored_content()
+ check_instance_settings()
+ check_version_changelog() + check_version_changelog()
+ check_migration_for_boundary() + check_migration_for_boundary()
+ check_migration_line_names_documents() + check_migration_line_names_documents()
+108 -1
View File
@@ -658,6 +658,104 @@ def check_tasks_provider() -> Check:
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}") return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}")
def _settings_file_check(name: str, path, in_instance: bool) -> tuple[Check, Optional[dict], bool]:
"""One settings file on its own: absent, unreadable, or read. The third
value says whether the file is usable for the joint check."""
from chemenu import settings
try:
data = settings.read_file(path)
except Exception as exc: # ValidationError, and anything else a broken file raises
return Check(name, "FAIL", str(exc), f"Fix or delete {path.name}"), None, False
if data is None:
what = ("no instance settings" if in_instance else "no settings for this checkout")
return Check(name, "OK", f"{path.name} absent - {what}"), None, True
return Check(name, "OK", f"{path.name} present"), data, True
def check_settings() -> list[Check]:
"""The tiered settings, one line per tier: `wikitool.json` (instance),
`.wikitool-local.json` (checkout), and the references both carry (secret).
A reference is reported as resolvable or not, never with its value. One
1Password cannot open without a human - locked, signed out, waiting for an
approval - is a `WARN`: nothing is wrong with the configuration, and a
`FAIL` would make `doctor` red whenever the app is locked."""
from chemenu import settings
from chemenu.errors import ValidationError
instance_check, instance_data, instance_ok = _settings_file_check(
"settings-instance", settings.instance_path(), True)
local_check, local_data, local_ok = _settings_file_check(
"settings-checkout", settings.local_path(), False)
if not (instance_ok and local_ok):
return [instance_check, local_check,
Check("settings-secrets", "WARN", "not checked - a settings file is unreadable",
"See the settings line above")]
if instance_data is None and local_data is None:
return [instance_check, local_check, Check("settings-secrets", "OK", "no references")]
try:
found = settings.check_files(instance_data, local_data)
except ValidationError as exc:
# A problem names the local file when it is that file's; everything
# else - the committed file's own, a value set in both, a required
# field nobody set - is reported on the instance line.
local_prefix = f"{config.LOCAL_SETTINGS_FILENAME}: "
problems = str(exc).splitlines()
for check, own in ((local_check, [p for p in problems if p.startswith(local_prefix)]),
(instance_check, [p for p in problems if not p.startswith(local_prefix)])):
if own:
check.status, check.detail = "FAIL", "; ".join(own)
check.fix = ("Fix the file as the message says - `wikitool config set` writes a "
"value where its tier puts it")
return [instance_check, local_check,
Check("settings-secrets", "WARN", "not checked - the settings files do not pass",
"See the settings lines above")]
for check, data, file_name in ((instance_check, instance_data, config.INSTANCE_SETTINGS_FILENAME),
(local_check, local_data, config.LOCAL_SETTINGS_FILENAME)):
if data is None:
continue
known = sorted(k for k in data if k not in ("schema", settings.INSTANCE_ID_KEY)
and k not in found.unknown.get(file_name, []))
check.detail = f"{file_name} present, sections: {', '.join(known) or 'none'}"
if file_name == config.INSTANCE_SETTINGS_FILENAME and found.instance_id:
check.detail += f"; instance id {found.instance_id}"
unknown = found.unknown.get(file_name)
if unknown:
check.status = "WARN"
check.detail += f"; not read by this version: {', '.join(unknown)}"
check.fix = "A newer stack may write these; otherwise remove them"
refs = list(found.refs())
if not refs:
return [instance_check, local_check, Check("settings-secrets", "OK", "no references")]
resolvable, unresolved, locked = [], [], []
for key, ref in refs:
try:
settings.resolve(ref, key=key, instance_id=found.instance_id)
except settings.SecretSourceLocked:
locked.append(key)
except ValidationError as exc:
unresolved.append(str(exc))
else:
resolvable.append(key)
summary = f"{len(resolvable)} of {len(refs)} reference(s) resolvable"
if unresolved:
return [instance_check, local_check, Check(
"settings-secrets", "FAIL", summary + "; not resolvable: " + "; ".join(unresolved),
"Provide each value where its message says - by the user, never by `config set`",
)]
if locked:
return [instance_check, local_check, Check(
"settings-secrets", "WARN",
summary + f"; 1Password would not answer for: {', '.join(locked)}",
"Unlock the 1Password app or run `op signin`, then run doctor again",
)]
return [instance_check, local_check, Check("settings-secrets", "OK", summary)]
def check_session_id() -> Check: def check_session_id() -> Check:
"""Three-valued, not two: an explicit `WIKITOOL_SESSION_ID` and a """Three-valued, not two: an explicit `WIKITOOL_SESSION_ID` and a
recognised harness variable (see `chemenu.session.HARNESS_ENV_VARS`) both recognised harness variable (see `chemenu.session.HARNESS_ENV_VARS`) both
@@ -778,6 +876,7 @@ def run_doctor() -> list[Check]:
check_publish_remotes(), check_publish_remotes(),
check_upload_intake(), check_upload_intake(),
check_tasks_provider(), check_tasks_provider(),
*check_settings(),
check_generated_files(), check_generated_files(),
check_session_id(), check_session_id(),
check_telemetry(), check_telemetry(),
@@ -835,6 +934,13 @@ def run_doctor() -> list[Check]:
"path is never attempted, and neither state is ever a `FAIL`.", "path is never attempted, and neither state is ever a `FAIL`.",
"For a configured `caldav` provider, whether the server is reachable and Basic auth " "For a configured `caldav` provider, whether the server is reachable and Basic auth "
"succeeds - never a `FAIL`; only a broken config block is.", "succeeds - never a `FAIL`; only a broken config block is.",
"Settings (`settings-instance`, `settings-checkout`, `settings-secrets`): one line per "
"tier. `wikitool.json` and `.wikitool-local.json` each present or absent - absent is "
"`OK` - and a `FAIL` naming file, key and the right tier when a value sits in the wrong "
"file, a secret or a committed private field holds a plain value, or a value fails its "
"field; a section this version does not read is a `WARN`. For the references: how many "
"resolve, never a value - an unresolvable one is a `FAIL`, one 1Password will not hand "
"out until a human unlocks it a `WARN`.",
"Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, " "Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, "
"`WARN` only for the bare parent-pid fallback.", "`WARN` only for the bare parent-pid fallback.",
"Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, " "Telemetry: on or off and why - installation-form default, `.wikitool-telemetry.json`, "
@@ -859,7 +965,8 @@ def run_doctor() -> list[Check]:
see_also=( see_also=(
"`instructions/setup-instance.md` - the setup steps most findings point back to", "`instructions/setup-instance.md` - the setup steps most findings point back to",
"`instructions/preflight.md` - what `tool-paths` and `install-dir` point back to", "`instructions/preflight.md` - what `tool-paths` and `install-dir` point back to",
"`docs/reference/configuration.md` - the per-checkout configuration files", "`docs/reference/configuration.md` - the per-checkout configuration files and the tiered "
"settings",
"`docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps", "`docs/explanation/telemetry-and-evaluation.md` - telemetry state and caps",
"`instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`", "`instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`",
), ),
+37
View File
@@ -274,6 +274,43 @@ TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG" ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
# The tiered settings files (`chemenu.settings`). Every field of a settings
# section belongs to exactly one tier, declared on its `config_shapes.Field`:
# `instance` values live in `wikitool.json`, which is committed and the same in
# every clone of the instance; `checkout` values live in `.wikitool-local.json`,
# which is gitignored and true only for this clone on this machine; `secret`
# values live in neither - both files hold only a reference to them.
#
# `wikitool.json` has no leading dot because it is meant to be seen and
# committed. `dist export` does not ship it - it belongs to the instance, and the
# first `wikitool config set` creates it - so nothing in a distribution names it
# but this constant.
INSTANCE_SETTINGS_FILENAME = "wikitool.json"
LOCAL_SETTINGS_FILENAME = ".wikitool-local.json"
# A `{"ref": "<name>"}` reference resolves through `WIKITOOL_SECRET_<NAME>`
# first, then through the secrets file below. A family of variables rather
# than one, so `_WIKITOOL_ENV` in tools/chemenu/tests/conftest.py clears it by
# prefix.
ENV_SECRET_PREFIX = "WIKITOOL_SECRET_"
def secrets_file(instance_id: str) -> Path:
"""Where a `{"ref": "<name>"}` reference looks once no variable answers it:
a file in the user's own configuration directory, outside every working
tree, keyed by the instance id `wikitool.json` carries so two instances on
one machine keep their secrets apart.
`%APPDATA%` on Windows; `$XDG_CONFIG_HOME`, else `~/.config`, everywhere
else."""
if os.name == "nt" and os.environ.get("APPDATA"):
base = Path(os.environ["APPDATA"])
else:
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "chemenu" / instance_id / "secrets.json"
def tasks_config_override() -> "Path | None": def tasks_config_override() -> "Path | None":
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank.""" """The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
value = os.environ.get(ENV_TASKS_CONFIG, "").strip() value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
+67 -1
View File
@@ -20,6 +20,12 @@ has, which is a different kind of change than writing the shape down.
hook handler reads it on every tool call. `deviations()` is the strict reading hook handler reads it on every tool call. `deviations()` is the strict reading
`doctor` uses to report what that silence would otherwise hide. `doctor` uses to report what that silence would otherwise hide.
The same records describe the sections of the two tiered settings files,
`wikitool.json` and `.wikitool-local.json` (`SETTINGS_SECTIONS`, read by
`chemenu.settings`). There every field also declares the tier it belongs to,
and the rule that a field naming a program or affecting a gate is never
`instance` is checked when the `Field` is declared (`Field.__post_init__`).
The three machine-written files beside them - `.wikitool-release.json`, The three machine-written files beside them - `.wikitool-release.json`,
`.wikitool-kb.json`, `.wikitool-tools.json` - are deliberately not here: nobody `.wikitool-kb.json`, `.wikitool-tools.json` - are deliberately not here: nobody
writes them by hand, so they have no shape for an operator to look up. writes them by hand, so they have no shape for an operator to look up.
@@ -39,6 +45,14 @@ from chemenu import config
IGNORE = "ignore" IGNORE = "ignore"
REJECT = "reject" REJECT = "reject"
# The three tiers a settings field can belong to (`chemenu.settings`). A field
# of one of the four per-feature files above carries none: those files predate
# the tiers and are read by their own loaders.
INSTANCE = "instance"
CHECKOUT = "checkout"
SECRET = "secret"
TIERS = (INSTANCE, CHECKOUT, SECRET)
_ABSENT = object() _ABSENT = object()
@@ -56,7 +70,17 @@ class Field:
applies to the value it finds - it may accept more than `json_type`, which applies to the value it finds - it may accept more than `json_type`, which
is the leniency `deviations()` reports - and raises `TypeError`/`ValueError` is the leniency `deviations()` reports - and raises `TypeError`/`ValueError`
on a value it cannot use. `check` then judges the coerced value and returns on a value it cannot use. `check` then judges the coerced value and returns
a problem, or None. A field with `fields` is a nested object.""" a problem, or None. A field with `fields` is a nested object.
The last four attributes matter only to a settings section
(`chemenu.settings`). `tier` says which file the field's value lives in.
`private` marks private infrastructure - a host name, an internal URL - that
may sit in the committed `wikitool.json` only as a reference, never as
plain text. `names_command` and `gate` mark a field that names a program to
run or that loosens or arms a gate. Such a field is never `instance`: a
`git pull` from another clone must not change what `wikitool` executes or
where it pushes. That rule is enforced here, when the field is declared,
rather than by whoever reads it."""
name: str name: str
type: str type: str
@@ -72,6 +96,41 @@ class Field:
required_why: str = "" required_why: str = ""
lenient: str = "" lenient: str = ""
fields: tuple["Field", ...] = () fields: tuple["Field", ...] = ()
tier: str = ""
private: bool = False
names_command: bool = False
gate: bool = False
def __post_init__(self) -> None:
where = f"config_shapes: field {self.name!r}"
if self.tier and self.tier not in TIERS:
raise ValueError(f"{where}: tier must be one of {', '.join(TIERS)}, not {self.tier!r}")
if not self.tier and (self.private or self.names_command or self.gate):
raise ValueError(f"{where}: `private`, `names_command` and `gate` need a tier")
if self.tier == INSTANCE and self.user_only:
raise ValueError(
f"{where}: a field that names a program to run or affects a gate is never "
f"tier {INSTANCE!r} - a pull from another clone would change what runs or "
f"where it pushes; declare it {CHECKOUT!r}"
)
if self.private and self.tier != CHECKOUT:
raise ValueError(
f"{where}: a private field is tier {CHECKOUT!r} - its plain value never goes "
"into the committed file, only a reference to it may"
)
if self.tier and self.fields:
raise ValueError(f"{where}: a settings field is a single value, not a nested object")
@property
def user_only(self) -> bool:
"""Set only by the user's own edit, never through `wikitool config set`."""
return self.names_command or self.gate
@property
def takes_ref(self) -> bool:
"""Whether a `{"ref": ...}` may stand in for the value - required for a
secret, optional for a private field, refused everywhere else."""
return self.tier == SECRET or self.private
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -393,6 +452,13 @@ TASKS = Shape(
SHAPES = (TELEMETRY, REMOTES, UPLOAD, TASKS) SHAPES = (TELEMETRY, REMOTES, UPLOAD, TASKS)
# The sections of the tiered settings files, `wikitool.json` and
# `.wikitool-local.json` (`chemenu.settings`). Each is a `Shape` whose `key` is
# the section's key in those files and whose every field declares its `tier`.
# Empty until the first feature reads its settings this way; `chemenu.settings`
# reads the tuple at call time, so a test can stand in a section of its own.
SETTINGS_SECTIONS: tuple[Shape, ...] = ()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Checking a value against a record # Checking a value against a record
+13 -1
View File
@@ -47,10 +47,19 @@ class Tool:
why: str why: str
@dataclass(frozen=True)
class OptionalTool:
"""A tool the preflight records when it finds it and never asks for."""
name: str
label: str
why: str
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
limits: dict[str, int] limits: dict[str, int]
tools: tuple[Tool, ...] tools: tuple[Tool, ...]
optional: tuple[OptionalTool, ...] = ()
def tools_for(self, platform: str) -> tuple[Tool, ...]: def tools_for(self, platform: str) -> tuple[Tool, ...]:
return tuple(t for t in self.tools if t.platforms in ("all", platform)) return tuple(t for t in self.tools if t.platforms in ("all", platform))
@@ -64,6 +73,7 @@ def load_manifest(path: Optional[Path] = None) -> Manifest:
path = manifest_path() if path is None else path path = manifest_path() if path is None else path
limits: dict[str, int] = {} limits: dict[str, int] = {}
tools: list[Tool] = [] tools: list[Tool] = []
optional: list[OptionalTool] = []
for line in path.read_text(encoding="utf-8").splitlines(): for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.startswith("#"): if not line.strip() or line.startswith("#"):
continue continue
@@ -73,7 +83,9 @@ def load_manifest(path: Optional[Path] = None) -> Manifest:
elif fields[0] == "tool": elif fields[0] == "tool":
minimum = None if fields[2] == "-" else fields[2] minimum = None if fields[2] == "-" else fields[2]
tools.append(Tool(fields[1], minimum, fields[3], fields[4], fields[5])) tools.append(Tool(fields[1], minimum, fields[3], fields[4], fields[5]))
return Manifest(limits, tuple(tools)) elif fields[0] == "optional":
optional.append(OptionalTool(fields[1], fields[2], fields[3]))
return Manifest(limits, tuple(tools), tuple(optional))
def platform() -> str: def platform() -> str:
+527
View File
@@ -0,0 +1,527 @@
"""The tiered settings: one committed file, one local file, and secrets outside the tree.
| Tier | File | Holds |
|---|---|---|
| `instance` | `wikitool.json`, committed | what belongs to the corpus and is the same in every clone |
| `checkout` | `.wikitool-local.json`, gitignored | what is true only for this clone on this machine |
| `secret` | neither | tokens and passwords; both files hold only a reference to them |
Every field of a section belongs to exactly one tier, declared on its
`config_shapes.Field`. A value in the wrong file is an error, never an override:
with two sources for one value, every reader would first have to work out which
one wins, and the answer would differ between clones.
**References instead of values.** A secret field takes only a reference, and a
private one (`Field.private`: host names, internal URLs) may take one:
- `{"ref": "<name>"}` - `WIKITOOL_SECRET_<NAME>` first, for CI and containers,
then the secrets file `config.secrets_file(<instance id>)` in the user's own
configuration directory, which must be readable by its owner only;
- `{"ref": "op://<vault>/<item>/<field>"}` - `op read`, the 1Password CLI the
preflight recorded in `.wikitool-tools.json`.
A reference is resolved when a reader asks for that one value, never while the
files are read and checked, and the value stays in this process's memory - it
is never cached on disk and never printed. A private field's plain value may sit
in the local file only: an instance that is public commits `wikitool.json`
publicly. As a reference it may sit in either, which is what makes the committed
file portable across every machine with the same 1Password account.
The sections themselves are `config_shapes.SETTINGS_SECTIONS`, read at call
time so a test can stand in a section of its own. Unknown top-level keys are
reported (`unknown`), not refused: a section a newer stack writes must not stop
an older one from reading the sections it knows.
**What this does not protect against**, stated where the mechanism is: an
agent with a shell, in the same user account, can run `op read` or read the
secrets file itself. Keeping secrets out of the tree keeps them out of commits,
out of files an agent reads in the course of ordinary work, and out of
`wikitool` output - not out of reach. docs/explanation/configuration-tiers.md
has the rest.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator, Optional
from chemenu import config, config_shapes, toolpaths
from chemenu.config_shapes import CHECKOUT, INSTANCE, SECRET, Field, Shape
from chemenu.errors import ChemenuError, ValidationError
SCHEMA = 1
INSTANCE_ID_KEY = "instance_id"
REF_KEY = "ref"
OP_SCHEME = "op://"
OP_TIMEOUT_SECONDS = 60
# Lower case, so `WIKITOOL_SECRET_<NAME>` is a plain upper-casing of it and two
# names cannot collapse onto one variable.
_NAME_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
# A directory name under the user's configuration directory: no separator, no
# leading dot, so it can never climb out of `chemenu/`.
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
# What `op read` says when the reference itself is wrong, as opposed to
# 1Password being out of reach. Everything else is read as "a human has to
# unlock, sign in or approve" - the conservative side of an unknown message.
_OP_NOT_FOUND = re.compile(
r"isn't (?:an item|a vault|a field)|does not have a field|no item found|could not find item",
re.IGNORECASE,
)
class SecretSourceLocked(ChemenuError):
"""1Password did not hand out a value because a human has to act first -
unlock it, sign in, or approve the request. The CLI renders this as exit 42:
not an error in the input, and nothing an agent can fix by retrying."""
@dataclass(frozen=True)
class Ref:
"""A reference in place of a value: a name, or an `op://` URI."""
target: str
@property
def is_op(self) -> bool:
return self.target.startswith(OP_SCHEME)
@property
def env_var(self) -> str:
return config.ENV_SECRET_PREFIX + self.target.upper()
def __str__(self) -> str:
return json.dumps({REF_KEY: self.target})
def is_ref(value: Any) -> bool:
"""Whether `value` is shaped as a reference at all - an object carrying
`ref`. Whether it is a *valid* one is `ref_problem`'s question."""
return isinstance(value, dict) and REF_KEY in value
def ref_problem(value: Any) -> Optional[str]:
"""Why `value` is not a valid reference, or None when it is one."""
if not isinstance(value, dict) or set(value) != {REF_KEY}:
return f'must be a reference of the form {{"{REF_KEY}": "<name>"}}, with no other key'
target = value[REF_KEY]
if not isinstance(target, str):
return "has a reference that is not a string"
if target.startswith(OP_SCHEME):
path = target[len(OP_SCHEME):].split("?", 1)[0]
parts = path.split("/")
if len(parts) < 3 or not all(part.strip() for part in parts):
return (f"has a 1Password reference that is not {OP_SCHEME}<vault>/<item>/<field> "
f"(or {OP_SCHEME}<vault>/<item>/<section>/<field>)")
return None
if not _NAME_RE.match(target):
return (f"has a reference name that is neither {OP_SCHEME}... nor lower-case letters, "
"digits and single underscores")
return None
def parse_ref(value: Any) -> Ref:
"""`value` as a `Ref`; raises `ValidationError` naming what is wrong."""
problem = ref_problem(value)
if problem:
raise ValidationError(f"the reference {problem}")
return Ref(value[REF_KEY])
# ---------------------------------------------------------------------------
# The sections
# ---------------------------------------------------------------------------
def section_problems(shape: Shape) -> list[str]:
"""What keeps `shape` from being a settings section: every field tiered,
no discriminator and no sub-sections - a field's tier has to be readable off
the field alone."""
where = f"settings section {shape.key!r}"
problems = [f"{where}: field {spec.name!r} has no tier" for spec in shape.fields if not spec.tier]
if shape.discriminator or shape.sections:
problems.append(f"{where}: a settings section takes neither variants nor sub-sections")
if shape.key in (INSTANCE_ID_KEY, "schema"):
problems.append(f"{where}: {shape.key!r} is a key of the file itself")
return problems
def sections() -> dict[str, Shape]:
"""The registered settings sections, by key. Raises `ValueError` on a
section that is not one - a programming error, not a configuration one."""
found: dict[str, Shape] = {}
for shape in config_shapes.SETTINGS_SECTIONS:
problems = section_problems(shape)
if problems:
raise ValueError("; ".join(problems))
if shape.key in found:
raise ValueError(f"settings section {shape.key!r} is registered twice")
found[shape.key] = shape
return found
def find_field(key: str, known: Optional[dict[str, Shape]] = None) -> tuple[Shape, Field]:
"""The section and field a `<section>.<field>` key names; raises
`ValidationError` naming what exists instead."""
known = sections() if known is None else known
section_name, dot, field_name = key.partition(".")
if not dot or not section_name or not field_name:
raise ValidationError(f"{key!r} is not a settings key - write it as <section>.<field>")
shape = known.get(section_name)
if shape is None:
offered = ", ".join(sorted(known)) or "none is registered in this version"
raise ValidationError(f"no settings section {section_name!r} (sections: {offered})")
for spec in shape.fields:
if spec.name == field_name:
return shape, spec
names = ", ".join(spec.name for spec in shape.fields)
raise ValidationError(f"section {section_name!r} has no field {field_name!r} (fields: {names})")
# ---------------------------------------------------------------------------
# Reading and checking
# ---------------------------------------------------------------------------
def instance_path(root: "Path | str | None" = None) -> Path:
return Path(config.ROOT if root is None else root) / config.INSTANCE_SETTINGS_FILENAME
def local_path(root: "Path | str | None" = None) -> Path:
return Path(config.ROOT if root is None else root) / config.LOCAL_SETTINGS_FILENAME
def read_file(path: Path) -> Optional[dict]:
"""The parsed file, or None when it does not exist. Raises
`ValidationError` for one that is not a JSON object."""
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError as exc:
raise ValidationError(f"{path.name} cannot be read ({exc})") from exc
try:
data = json.loads(text)
except ValueError as exc:
raise ValidationError(f"{path.name} is not valid JSON ({exc})") from exc
if not isinstance(data, dict):
raise ValidationError(f"{path.name} must contain a JSON object")
return data
def _file_label(in_instance: bool) -> str:
return config.INSTANCE_SETTINGS_FILENAME if in_instance else config.LOCAL_SETTINGS_FILENAME
def placement_problem(spec: Field, value: Any, in_instance: bool, key: str) -> Optional[str]:
"""Why `value` may not stand for `spec` in this file, or None. Never
quotes the value: a plain secret is exactly what must not be printed."""
here = _file_label(in_instance)
if is_ref(value):
problem = ref_problem(value)
if problem:
return f"{here}: {key} {problem}"
if not spec.takes_ref:
return (f"{here}: {key} takes a plain value, not a reference - only a secret or a "
"private field takes one")
if in_instance and spec.user_only:
return (f"{here}: {key} names a program to run or affects a gate, so it is never in "
f"{config.INSTANCE_SETTINGS_FILENAME}, not even as a reference - it belongs to "
f"tier {CHECKOUT!r}, in {config.LOCAL_SETTINGS_FILENAME}")
return None
if spec.tier == SECRET:
return (f"{here}: {key} is tier {SECRET!r} and holds a plain value - a secret is only ever "
f'a reference, {{"{REF_KEY}": "<name>"}} or {{"{REF_KEY}": "{OP_SCHEME}..."}}; '
f"the value itself goes into {config.ENV_SECRET_PREFIX}<NAME>, the secrets file "
"or 1Password")
if spec.private and in_instance:
return (f"{here}: {key} is private and holds a plain value in the committed file - move "
f"it to {config.LOCAL_SETTINGS_FILENAME} (tier {CHECKOUT!r}), or replace it with a "
"reference")
if (spec.tier == INSTANCE) != in_instance:
return (f"{here}: {key} belongs to tier {spec.tier!r} - move it to "
f"{_file_label(spec.tier == INSTANCE)}")
return None
def _envelope_problems(data: Optional[dict], in_instance: bool) -> list[str]:
if data is None:
return []
here = _file_label(in_instance)
problems = []
if "schema" in data and data["schema"] != SCHEMA:
problems.append(f"{here}: schema must be {SCHEMA}")
if INSTANCE_ID_KEY in data:
value = data[INSTANCE_ID_KEY]
if not in_instance:
problems.append(f"{here}: {INSTANCE_ID_KEY} belongs to tier {INSTANCE!r} - move it to "
f"{config.INSTANCE_SETTINGS_FILENAME}")
elif not isinstance(value, str) or not _INSTANCE_ID_RE.match(value):
problems.append(f"{here}: {INSTANCE_ID_KEY} must be letters, digits, '-' and '_', "
"starting with a letter or digit")
return problems
@dataclass
class Section:
"""One section as both files give it: plain values (coerced, defaults
filled in) and references, resolved only on access."""
shape: Shape
values: dict = field(default_factory=dict)
refs: dict = field(default_factory=dict)
given: set = field(default_factory=set)
instance_id: Optional[str] = None
@property
def configured(self) -> bool:
return bool(self.given)
def __getitem__(self, name: str) -> Any:
if name in self.refs:
return resolve(self.refs[name], key=f"{self.shape.key}.{name}", instance_id=self.instance_id)
return self.values[name]
def get(self, name: str, default: Any = None) -> Any:
if name in self.refs or name in self.values:
return self[name]
return default
@dataclass
class Settings:
instance_present: bool = False
local_present: bool = False
instance_id: Optional[str] = None
by_section: dict = field(default_factory=dict)
unknown: dict = field(default_factory=dict)
def section(self, name: str) -> Section:
"""The section `name`, empty (not `configured`) when no file sets it."""
found = self.by_section.get(name)
if found is not None:
return found
shape = sections()[name]
empty = config_shapes.check(shape, {}).values
return Section(shape, values=empty, instance_id=self.instance_id)
def refs(self) -> Iterator[tuple[str, Ref]]:
"""Every reference in either file, as `(<section>.<field>, ref)`."""
for name, section in sorted(self.by_section.items()):
for field_name, ref in sorted(section.refs.items()):
yield f"{name}.{field_name}", ref
def check_files(
instance_data: Optional[dict], local_data: Optional[dict],
known: Optional[dict[str, Shape]] = None, *, complete: bool = True,
) -> Settings:
"""Both files' contents checked against the registered sections. Raises
`ValidationError` listing every problem, one per line; resolves nothing.
`complete=False` is the check for a write in progress (`config set`): a
required field nobody has set yet is not a problem there, or the first
value of a section could never be written."""
known = sections() if known is None else known
problems = _envelope_problems(instance_data, True) + _envelope_problems(local_data, False)
settings = Settings(instance_present=instance_data is not None, local_present=local_data is not None)
if instance_data is not None and isinstance(instance_data.get(INSTANCE_ID_KEY), str):
settings.instance_id = instance_data[INSTANCE_ID_KEY]
merged: dict[str, dict[str, tuple[Any, bool]]] = {}
for data, in_instance in ((instance_data, True), (local_data, False)):
if data is None:
continue
here = _file_label(in_instance)
for name, body in data.items():
if name in ("schema", INSTANCE_ID_KEY):
continue
shape = known.get(name)
if shape is None:
settings.unknown.setdefault(here, []).append(name)
continue
if not isinstance(body, dict):
problems.append(f"{here}: {name} must be an object")
continue
fields = {spec.name: spec for spec in shape.fields}
for field_name, value in body.items():
key = f"{name}.{field_name}"
spec = fields.get(field_name)
if spec is None:
problems.append(f"{here}: {key} is not a field of {name!r} "
f"(fields: {', '.join(fields)})")
continue
problem = placement_problem(spec, value, in_instance, key)
if problem:
problems.append(problem)
continue
if field_name in merged.setdefault(name, {}):
problems.append(f"{key} is set in both {config.INSTANCE_SETTINGS_FILENAME} and "
f"{config.LOCAL_SETTINGS_FILENAME} - a value has exactly one place")
continue
merged[name][field_name] = (value, in_instance)
for name, entries in merged.items():
shape = known[name]
plain = {k: v for k, (v, _) in entries.items() if not is_ref(v)}
refs = {k: Ref(v[REF_KEY]) for k, (v, _) in entries.items() if is_ref(v)}
checked = config_shapes.check(shape, plain)
for problem in checked.problems:
if problem.field in refs:
continue # "missing" only because a reference stands in for it
if not complete and problem.field not in entries:
continue # not set yet - the write in progress may be the first of several
origin = entries.get(problem.field)
where = f"{_file_label(origin[1])}: " if origin else ""
problems.append(f"{where}{name}.{problem.message}")
settings.by_section[name] = Section(
shape, values=checked.values, refs=refs,
given=set(checked.given) | set(refs), instance_id=settings.instance_id,
)
if problems:
raise ValidationError("\n".join(problems))
return settings
def committed_file_problems(instance_data: dict, known: Optional[dict[str, Shape]] = None) -> list[str]:
"""What is wrong with `wikitool.json` on its own: its envelope, and every
value in a place its tier forbids - a plain secret or private value above
all. Unlike `check_files`, it does not ask for a required field: that one
may live in a local file this check never sees."""
known = sections() if known is None else known
problems = _envelope_problems(instance_data, True)
for name, body in instance_data.items():
shape = known.get(name)
if shape is None or not isinstance(body, dict):
continue
fields = {spec.name: spec for spec in shape.fields}
for field_name, value in body.items():
spec = fields.get(field_name)
problem = (placement_problem(spec, value, True, f"{name}.{field_name}") if spec else
f"{config.INSTANCE_SETTINGS_FILENAME}: {name}.{field_name} is not a field of {name!r}")
if problem:
problems.append(problem)
return problems
def load(root: "Path | str | None" = None) -> Settings:
"""Both files under `root` (default `config.ROOT`), read and checked."""
return check_files(read_file(instance_path(root)), read_file(local_path(root)))
# ---------------------------------------------------------------------------
# Resolving a reference
# ---------------------------------------------------------------------------
def _op_missing(key: str) -> str:
return (f"{key}: the reference needs the 1Password CLI (`op`), and this checkout has none "
f"recorded - install it, then {toolpaths.PREFLIGHT}, which records it")
def _locked(key: str, why: str) -> str:
return (f"{key}: 1Password {why} - it is locked, not signed in, or waiting for approval. "
"Unlock the 1Password app or run `op signin` (in CI: set OP_SERVICE_ACCOUNT_TOKEN), "
"then run this command again.")
def read_secrets_file(path: Path) -> Optional[dict]:
"""The secrets file, or None when it does not exist. Raises
`ValidationError` when it is readable by anyone but its owner, or not a
JSON object of strings - without quoting any of it."""
try:
mode = path.stat().st_mode
except FileNotFoundError:
return None
if os.name != "nt" and mode & 0o077:
raise ValidationError(f"{path} is readable by other users - restrict it: chmod 600 {path}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise ValidationError(f"{path} cannot be read as JSON ({type(exc).__name__})") from None
if not isinstance(data, dict) or not all(isinstance(v, str) for v in data.values()):
raise ValidationError(f"{path} must be a JSON object of names to strings")
return data
def _resolve_named(ref: Ref, key: str, instance_id: Optional[str]) -> str:
value = os.environ.get(ref.env_var, "")
if value:
return value
if not instance_id:
raise ValidationError(
f"{key}: {ref} cannot be resolved - {ref.env_var} is not set, and "
f"{config.INSTANCE_SETTINGS_FILENAME} has no {INSTANCE_ID_KEY} to find the secrets "
"file by (the first `wikitool config set` writes one)"
)
path = config.secrets_file(instance_id)
data = read_secrets_file(path) or {}
value = data.get(ref.target, "")
if value:
return value
raise ValidationError(
f"{key}: {ref} cannot be resolved - set {ref.env_var}, or add \"{ref.target}\" to {path}"
)
def _resolve_op(ref: Ref, key: str) -> str:
program = toolpaths.optional("op")
if program is None:
raise ValidationError(_op_missing(key))
try:
result = subprocess.run(
[program, "read", "--no-newline", ref.target],
capture_output=True, text=True, encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL, timeout=OP_TIMEOUT_SECONDS, check=False,
)
except FileNotFoundError:
raise ValidationError(_op_missing(key)) from None
except subprocess.TimeoutExpired:
raise SecretSourceLocked(_locked(key, f"did not answer within {OP_TIMEOUT_SECONDS} s")) from None
except OSError as exc:
raise ValidationError(f"{key}: `op` could not be started ({exc}) - {toolpaths.PREFLIGHT}") from None
if result.returncode == 0:
return result.stdout
if _OP_NOT_FOUND.search(result.stderr or ""):
raise ValidationError(
f"{key}: 1Password has no value at {ref.target} - check the vault, item and field names"
)
raise SecretSourceLocked(_locked(key, "did not hand out the value"))
def resolve(ref: Ref, *, key: str, instance_id: Optional[str]) -> str:
"""The value behind `ref`, for the setting `key`. Raises `ValidationError`
when it cannot be found and `SecretSourceLocked` when a human has to unlock
1Password first. Neither message carries the value."""
if ref.is_op:
return _resolve_op(ref, key)
return _resolve_named(ref, key, instance_id)
# ---------------------------------------------------------------------------
# Writing (`wikitool config set`)
# ---------------------------------------------------------------------------
def new_instance_id() -> str:
return str(uuid.uuid4())
def write_file(path: Path, data: dict) -> None:
"""Replace `path` with `data` in one rename, so a reader never sees half a file."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
+4
View File
@@ -160,6 +160,10 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
for name in (*_WIKITOOL_ENV, *_GIT_ENV): for name in (*_WIKITOOL_ENV, *_GIT_ENV):
monkeypatch.delenv(name, raising=False) monkeypatch.delenv(name, raising=False)
# A family rather than a name: `{"ref": "<name>"}` reads
# `WIKITOOL_SECRET_<NAME>` for any name, so every one is cleared.
for name in [n for n in os.environ if n.startswith(config.ENV_SECRET_PREFIX)]:
monkeypatch.delenv(name, raising=False)
# `.wikitool-tools.json` beside this checkout records where the developer's # `.wikitool-tools.json` beside this checkout records where the developer's
# git and rg live. A test must not depend on whether the preflight has run # git and rg live. A test must not depend on whether the preflight has run
+13
View File
@@ -202,6 +202,19 @@ def test_plan_ships_no_retired_root_docs(repo):
assert "README.md" in plan assert "README.md" in plan
def test_plan_ships_neither_settings_file(repo):
"""`wikitool.json` belongs to the instance that wrote it - its id, its
references - and `.wikitool-local.json` to one checkout. A distribution
carries neither; the first `config set` in the new instance creates its own."""
(repo / config.INSTANCE_SETTINGS_FILENAME).write_text(
'{"schema": 1, "instance_id": "origin-only"}\n', encoding="utf-8")
(repo / config.LOCAL_SETTINGS_FILENAME).write_text('{"schema": 1}\n', encoding="utf-8")
plan = dist_cmd.build_plan()
assert config.INSTANCE_SETTINGS_FILENAME not in plan
assert config.LOCAL_SETTINGS_FILENAME not in plan
assert "README.md" in plan
def test_plan_never_includes_instructions_dev(repo): def test_plan_never_includes_instructions_dev(repo):
"""instructions/dev/ - flat dev-only instructions and the nested skill """instructions/dev/ - flat dev-only instructions and the nested skill
that switches a session into tool-development mode - is pruned that switches a session into tool-development mode - is pruned
+34
View File
@@ -185,6 +185,40 @@ def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine, shel
assert "Preflight passed" in result.stdout assert "Preflight passed" in result.stdout
@pytest.mark.parametrize("shell", SHELLS)
def test_an_optional_op_is_recorded_when_found(machine, shell):
machine.standard()
machine.stub("op", "#!/bin/sh\necho '2.30.0'\n")
result = machine.run(shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert machine.recorded()["tools"]["op"] == str(machine.stubs / "op")
assert any(line.split()[:3] == ["OK", "op", "2.30"] for line in result.stdout.splitlines())
@pytest.mark.parametrize("shell", SHELLS)
def test_an_absent_optional_op_is_no_problem_and_not_recorded(machine, shell):
machine.standard()
result = machine.run(shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert "op" not in machine.recorded()["tools"]
assert any(line.split()[:2] == ["ABSENT", "op"] for line in result.stdout.splitlines())
@pytest.mark.parametrize("shell", SHELLS)
def test_set_names_an_optional_tool(machine, shell):
machine.standard()
elsewhere = machine.stub("op", "#!/bin/sh\necho '2.30.0'\n", where=machine.base / "elsewhere")
result = machine.run("--set", f"op={elsewhere}", shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert machine.recorded()["tools"]["op"] == str(elsewhere)
def test_the_manifest_names_op_as_optional_only():
manifest = prerequisites.load_manifest()
assert [tool.name for tool in manifest.optional] == ["op"]
assert "op" not in {tool.name for tool in manifest.tools}
@pytest.mark.parametrize("shell", SHELLS) @pytest.mark.parametrize("shell", SHELLS)
def test_second_run_changes_nothing(machine, shell): def test_second_run_changes_nothing(machine, shell):
machine.standard() machine.standard()
@@ -95,6 +95,24 @@ def test_both_preflights_record_the_same_file(machine):
assert machine.tools_file.read_bytes() == from_pwsh assert machine.tools_file.read_bytes() == from_pwsh
@pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
def test_both_preflights_record_an_optional_op_the_same_way(machine):
machine.stub("op", "#!/bin/sh\necho '2.30.0'\n")
assert _preflight(machine).returncode == 0
from_pwsh = machine.tools_file.read_bytes()
assert json.loads(from_pwsh)["tools"]["op"] == str(machine.stubs / "op")
machine.tools_file.unlink()
assert machine.run(shell=SHELLS[0]).returncode == 0
assert machine.tools_file.read_bytes() == from_pwsh
def test_an_absent_optional_op_is_no_problem(machine):
result = _preflight(machine)
assert result.returncode == 0, result.stdout + result.stderr
assert "op" not in machine.recorded()["tools"]
assert any(line.split()[:2] == ["ABSENT", "op"] for line in result.stdout.splitlines())
def test_help_prints_the_usage_and_exits_0(machine): def test_help_prints_the_usage_and_exits_0(machine):
result = _preflight(machine, "--help") result = _preflight(machine, "--help")
assert result.returncode == 0 assert result.returncode == 0
+574
View File
@@ -0,0 +1,574 @@
"""The tiered settings (`chemenu.settings`), `wikitool config set`, and what
`doctor` and `docs verify` report about them.
No real section ships yet, so every test stands in `TRACKER`, a section with one
field of each kind: an instance value, a private checkout value, a secret, a
field that names a program, and a plain checkout value. 1Password is a fake `op`
script written into the test's own directory - never a real account; the one
exception is the CI smoke test at the bottom, which only runs where CI has
loaded a real `op://` value into the environment.
"""
from __future__ import annotations
import json
import os
import shlex
import stat
import subprocess
from pathlib import Path
import pytest
import typer
from typer.testing import CliRunner
from chemenu import config, config_shapes, settings, toolpaths
from chemenu.cli import app
from chemenu.commands import _util, docs_verify, doctor
from chemenu.config_shapes import CHECKOUT, INSTANCE, SECRET, Field, Shape
from chemenu.errors import ValidationError
runner = CliRunner()
# Captured at import, before the autouse fixture clears every
# `WIKITOOL_SECRET_*` for the test run - see the CI smoke test at the bottom.
_LIVE_VALUE = os.environ.get("WIKITOOL_SECRET_CI_SMOKE", "")
_LIVE_REQUIRED = os.environ.get("CHEMENU_LIVE_SECRET_REQUIRE", "") == "1"
needs_posix = pytest.mark.skipif(os.name == "nt", reason="the fake `op` is a POSIX shell script")
SECRET_VALUE = "s3cr3t-never-printed"
PRIVATE_VALUE = "https://tracker.internal.example"
def _positive(value: int):
return None if value > 0 else "must be positive"
TRACKER = Shape(
key="tracker", title="Test tracker", purpose="A section with one field of each kind.",
fields=(
Field("stale_days", "positive integer", "Instance threshold.", tier=INSTANCE,
json_type="integer", coerce=int, check=_positive, default=14),
Field("url", "string", "Private infrastructure.", tier=CHECKOUT, private=True,
json_type="string", required=True),
Field("token", "string", "A secret.", tier=SECRET, json_type="string"),
Field("program", "string", "Names a program, privately.", tier=CHECKOUT, private=True,
names_command=True, json_type="string"),
Field("label", "string", "Plain checkout value.", tier=CHECKOUT, json_type="string"),
),
examples=({},),
unknown_keys=config_shapes.REJECT,
)
@pytest.fixture
def root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
tree = tmp_path / "instance"
tree.mkdir()
monkeypatch.setattr(config, "ROOT", tree)
monkeypatch.setattr(config_shapes, "SETTINGS_SECTIONS", (TRACKER,))
return tree
def _write(root: Path, instance: dict | None = None, local: dict | None = None) -> None:
if instance is not None:
(root / config.INSTANCE_SETTINGS_FILENAME).write_text(json.dumps(instance), encoding="utf-8")
if local is not None:
(root / config.LOCAL_SETTINGS_FILENAME).write_text(json.dumps(local), encoding="utf-8")
def _out(result) -> str:
"""The command's output with Rich's line wrapping undone."""
return " ".join(result.output.split())
def _squashed(result) -> str:
"""The output with every whitespace removed - for asserting a value is
absent, where a value Rich wrapped mid-word must still be found."""
return "".join(result.output.split())
def _read(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _fake_op(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, values: dict | None = None,
locked: bool = False, hang: bool = False) -> Path:
"""An `op` that answers `op read --no-newline <ref>` from `values`, says
"not signed in" when `locked`, or never answers when `hang` - recorded in a
`.wikitool-tools.json` of the test's own, the way the preflight records it."""
lines = ["#!/bin/sh",
'[ "$1" = read ] && [ "$2" = --no-newline ] || { echo "[ERROR] bad call: $*" >&2; exit 2; }']
if hang:
lines.append("sleep 10")
if locked:
lines += ['echo "[ERROR] 2026/10/10 12:00:00 You are not currently signed in." >&2', "exit 1"]
lines.append('case "$3" in')
for ref, value in (values or {}).items():
lines.append(f" {shlex.quote(ref)}) printf '%s' {shlex.quote(value)}; exit 0 ;;")
lines += ["esac",
"""echo "[ERROR] 2026/10/10 12:00:00 could not read secret '$3': \\"item\\" isn't an item in the \\"Vault\\" vault" >&2""",
"exit 1"]
script = tmp_path / "bin" / "op"
script.parent.mkdir(exist_ok=True)
script.write_text("\n".join(lines) + "\n", encoding="utf-8")
script.chmod(0o755)
tools = tmp_path / toolpaths.FILE_NAME
tools.write_text(json.dumps({"schema": 1, "complete": True, "tools": {"op": str(script)}}), encoding="utf-8")
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools)
return script
def _command_app(body) -> typer.Typer:
"""A one-command app standing in for a future consumer of the settings,
which reads them through the same two helpers."""
test_app = typer.Typer()
@test_app.command()
def run():
body()
@test_app.callback()
def _callback():
pass
return test_app
def _read_token():
typer.echo(f"length {len(_util.setting_value(_util.load_settings().section('tracker'), 'token'))}")
# --- declaring a field ----------------------------------------------------------
@pytest.mark.parametrize("flag", ["names_command", "gate"])
def test_a_field_that_names_a_program_or_a_gate_cannot_be_declared_instance(flag):
with pytest.raises(ValueError, match="never tier 'instance'"):
Field("x", "string", "x", tier=INSTANCE, **{flag: True})
assert Field("x", "string", "x", tier=CHECKOUT, **{flag: True}).user_only
def test_a_private_field_is_tier_checkout():
with pytest.raises(ValueError, match="private field is tier 'checkout'"):
Field("x", "string", "x", tier=INSTANCE, private=True)
def test_the_settings_flags_need_a_tier():
with pytest.raises(ValueError, match="need a tier"):
Field("x", "string", "x", private=True)
def test_an_unknown_tier_is_refused():
with pytest.raises(ValueError, match="tier must be one of"):
Field("x", "string", "x", tier="global")
def test_every_shipped_settings_section_is_one():
for shape in config_shapes.SETTINGS_SECTIONS:
assert settings.section_problems(shape) == []
# The four per-feature files predate the tiers and declare none.
for shape in config_shapes.SHAPES:
assert all(not spec.tier for spec in shape.fields)
# --- reading: the right file ----------------------------------------------------
def test_no_files_read_as_nothing_configured(root):
found = settings.load()
assert not found.instance_present and not found.local_present
assert not found.section("tracker").configured
assert found.section("tracker")["stale_days"] == 14
def test_values_in_their_own_files_are_read(root):
_write(root, {"schema": 1, "instance_id": "abc", "tracker": {"stale_days": 21}},
{"tracker": {"url": PRIVATE_VALUE, "label": "mine"}})
section = settings.load().section("tracker")
assert section["stale_days"] == 21
assert section["url"] == PRIVATE_VALUE
assert section["label"] == "mine"
@pytest.mark.parametrize("instance,local,file_name,key,tier", [
(None, {"tracker": {"stale_days": 7, "url": "x"}}, ".wikitool-local.json", "tracker.stale_days", "instance"),
({"tracker": {"label": "mine"}}, {"tracker": {"url": "x"}}, "wikitool.json", "tracker.label", "checkout"),
])
def test_a_value_in_the_wrong_tier_fails_naming_file_key_and_tier(root, instance, local, file_name, key, tier):
_write(root, instance, local)
with pytest.raises(ValidationError) as caught:
settings.load()
assert f"{file_name}: {key} belongs to tier '{tier}'" in str(caught.value)
result = runner.invoke(_command_app(lambda: _util.load_settings()), ["run"])
assert result.exit_code == 1
assert f"{file_name}: {key} belongs to tier '{tier}'" in _out(result)
def test_a_value_set_in_both_files_is_an_error_not_an_override(root):
_write(root, {"tracker": {"url": {"ref": "tracker_url"}}}, {"tracker": {"url": "x"}})
with pytest.raises(ValidationError, match="set in both"):
settings.load()
def test_a_program_field_is_never_in_the_committed_file_even_as_a_reference(root):
_write(root, {"tracker": {"program": {"ref": "op://V/i/f"}}}, {"tracker": {"url": "x"}})
with pytest.raises(ValidationError, match="not even as a reference"):
settings.load()
def test_a_reference_where_a_plain_value_belongs_is_refused(root):
_write(root, {"tracker": {"stale_days": {"ref": "days"}}}, {"tracker": {"url": "x"}})
with pytest.raises(ValidationError, match="takes a plain value, not a reference"):
settings.load()
def test_a_missing_required_field_is_reported_unless_a_reference_stands_in(root):
_write(root, {"tracker": {"stale_days": 3}})
with pytest.raises(ValidationError, match="tracker.url is missing"):
settings.load()
_write(root, {"tracker": {"stale_days": 3, "url": {"ref": "tracker_url"}}})
assert settings.load().section("tracker").refs["url"] == settings.Ref("tracker_url")
def test_an_unknown_section_is_reported_not_refused(root):
_write(root, {"newer": {"x": 1}})
assert settings.load().unknown == {"wikitool.json": ["newer"]}
@pytest.mark.parametrize("ref", [{"ref": "Upper"}, {"ref": "op://only/two"}, {"ref": 3},
{"ref": "x", "extra": 1}])
def test_a_malformed_reference_is_refused(root, ref):
_write(root, {"tracker": {"token": ref}}, {"tracker": {"url": "x"}})
with pytest.raises(ValidationError):
settings.load()
# --- reading: no plain secret, no plain private value in the committed file -----
@pytest.mark.parametrize("instance,local", [
({"tracker": {"token": SECRET_VALUE}}, {"tracker": {"url": "x"}}),
(None, {"tracker": {"url": "x", "token": SECRET_VALUE}}),
({"tracker": {"url": SECRET_VALUE}}, None),
])
def test_a_plain_secret_or_committed_private_value_fails_without_showing_it(root, instance, local):
_write(root, instance, local)
with pytest.raises(ValidationError) as caught:
settings.load()
assert SECRET_VALUE not in str(caught.value)
result = runner.invoke(_command_app(lambda: _util.load_settings()), ["run"])
assert result.exit_code == 1
assert SECRET_VALUE not in _squashed(result)
checks = doctor.check_settings()
assert any(c.status == "FAIL" for c in checks)
assert SECRET_VALUE not in json.dumps([c.__dict__ for c in checks])
def test_docs_verify_flags_a_plain_secret_in_the_committed_file_only(root):
_write(root, {"tracker": {"token": SECRET_VALUE, "url": SECRET_VALUE}})
problems = docs_verify.check_instance_settings()
assert len(problems) == 2
assert SECRET_VALUE not in "\n".join(problems)
# The local file is never committed, and a required field may live there.
_write(root, {"tracker": {"token": {"ref": "tracker_token"}}}, {"tracker": {"token": SECRET_VALUE}})
assert docs_verify.check_instance_settings() == []
def test_docs_verify_accepts_an_absent_committed_file(root):
assert docs_verify.check_instance_settings() == []
# --- resolving a named reference ------------------------------------------------
def test_a_named_reference_resolves_from_its_variable(root, monkeypatch):
_write(root, {"instance_id": "abc", "tracker": {"token": {"ref": "tracker_token"}}},
{"tracker": {"url": "x"}})
monkeypatch.setenv("WIKITOOL_SECRET_TRACKER_TOKEN", SECRET_VALUE)
assert settings.load().section("tracker")["token"] == SECRET_VALUE
def test_without_the_variable_it_resolves_from_the_secrets_file_in_the_user_s_home(root, hermetic_environment):
_write(root, {"instance_id": "abc", "tracker": {"token": {"ref": "tracker_token"}}},
{"tracker": {"url": "x"}})
secrets_file = config.secrets_file("abc")
assert secrets_file == hermetic_environment / ".config" / "chemenu" / "abc" / "secrets.json"
secrets_file.parent.mkdir(parents=True)
secrets_file.write_text(json.dumps({"tracker_token": SECRET_VALUE}), encoding="utf-8")
secrets_file.chmod(0o600)
assert settings.load().section("tracker")["token"] == SECRET_VALUE
@needs_posix
def test_a_secrets_file_others_can_read_is_refused(root, hermetic_environment):
_write(root, {"instance_id": "abc", "tracker": {"token": {"ref": "tracker_token"}}},
{"tracker": {"url": "x"}})
secrets_file = config.secrets_file("abc")
secrets_file.parent.mkdir(parents=True)
secrets_file.write_text(json.dumps({"tracker_token": SECRET_VALUE}), encoding="utf-8")
secrets_file.chmod(0o644)
with pytest.raises(ValidationError, match="chmod 600"):
settings.load().section("tracker")["token"]
def test_an_unresolvable_name_says_where_to_put_the_value(root):
_write(root, {"instance_id": "abc", "tracker": {"token": {"ref": "tracker_token"}}},
{"tracker": {"url": "x"}})
with pytest.raises(ValidationError, match="WIKITOOL_SECRET_TRACKER_TOKEN"):
settings.load().section("tracker")["token"]
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 1
def test_a_reference_is_resolved_on_access_not_on_load(root):
_write(root, {"instance_id": "abc", "tracker": {"token": {"ref": "tracker_token"}}},
{"tracker": {"url": "x"}})
section = settings.load().section("tracker") # no error: nothing resolved yet
assert section["url"] == "x"
# --- resolving an op:// reference -----------------------------------------------
@needs_posix
def test_an_op_reference_resolves_through_op(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, values={"op://Vault/tracker/url": PRIVATE_VALUE,
"op://Vault/tracker/token": SECRET_VALUE})
_write(root, {"tracker": {"url": {"ref": "op://Vault/tracker/url"},
"token": {"ref": "op://Vault/tracker/token"}}})
section = settings.load().section("tracker")
assert section["url"] == PRIVATE_VALUE
assert section["token"] == SECRET_VALUE
@needs_posix
def test_a_locked_op_is_exit_42(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, locked=True)
_write(root, {"tracker": {"token": {"ref": "op://Vault/tracker/token"}}}, {"tracker": {"url": "x"}})
with pytest.raises(settings.SecretSourceLocked):
settings.load().section("tracker")["token"]
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 42
assert "NEEDS USER CLEARANCE" in _out(result)
assert "op://Vault" not in _squashed(result)
@needs_posix
def test_an_op_that_does_not_answer_in_time_is_exit_42(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, hang=True)
monkeypatch.setattr(settings, "OP_TIMEOUT_SECONDS", 1)
_write(root, {"tracker": {"token": {"ref": "op://Vault/tracker/token"}}}, {"tracker": {"url": "x"}})
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 42
@needs_posix
def test_a_missing_item_is_exit_1_naming_the_reference(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, values={})
_write(root, {"tracker": {"token": {"ref": "op://Vault/tracker/token"}}}, {"tracker": {"url": "x"}})
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 1
assert "op://Vault/tracker/token" in _out(result)
def test_a_missing_op_is_exit_1_pointing_at_the_preflight(root, tmp_path, monkeypatch):
tools = tmp_path / toolpaths.FILE_NAME
tools.write_text(json.dumps({"schema": 1, "complete": True, "tools": {}}), encoding="utf-8")
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools)
_write(root, {"tracker": {"token": {"ref": "op://Vault/tracker/token"}}}, {"tracker": {"url": "x"}})
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 1
assert "preflight" in _out(result)
def test_without_a_tools_file_an_absent_op_is_exit_1_too(root, monkeypatch):
monkeypatch.setenv("PATH", "")
_write(root, {"tracker": {"token": {"ref": "op://Vault/tracker/token"}}}, {"tracker": {"url": "x"}})
result = runner.invoke(_command_app(_read_token), ["run"])
assert result.exit_code == 1
assert "1Password CLI" in _out(result)
# --- doctor ---------------------------------------------------------------------
def _by_name(checks) -> dict:
return {c.name: c for c in checks}
def test_doctor_reports_each_tier_absent_as_ok(root):
checks = _by_name(doctor.check_settings())
assert [c.status for c in checks.values()] == ["OK", "OK", "OK"]
assert "absent" in checks["settings-instance"].detail
assert "absent" in checks["settings-checkout"].detail
assert checks["settings-secrets"].detail == "no references"
def test_doctor_reports_a_malformed_file_as_fail(root):
(root / config.INSTANCE_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
checks = _by_name(doctor.check_settings())
assert checks["settings-instance"].status == "FAIL"
assert checks["settings-checkout"].status == "OK"
@needs_posix
def test_doctor_reports_references_as_resolvable_or_not_never_their_value(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, values={"op://Vault/tracker/url": PRIVATE_VALUE})
_write(root, {"instance_id": "abc", "tracker": {"url": {"ref": "op://Vault/tracker/url"},
"token": {"ref": "tracker_token"}}})
monkeypatch.setenv("WIKITOOL_SECRET_TRACKER_TOKEN", SECRET_VALUE)
checks = _by_name(doctor.check_settings())
assert checks["settings-secrets"].status == "OK"
assert checks["settings-secrets"].detail == "2 of 2 reference(s) resolvable"
assert "instance id abc" in checks["settings-instance"].detail
monkeypatch.delenv("WIKITOOL_SECRET_TRACKER_TOKEN")
checks = _by_name(doctor.check_settings())
assert checks["settings-secrets"].status == "FAIL"
assert "1 of 2" in checks["settings-secrets"].detail
dumped = json.dumps([c.__dict__ for c in checks.values()])
assert SECRET_VALUE not in dumped and PRIVATE_VALUE not in dumped
@needs_posix
def test_doctor_warns_rather_than_fails_on_a_locked_op(root, tmp_path, monkeypatch):
_fake_op(tmp_path, monkeypatch, locked=True)
_write(root, {"tracker": {"url": {"ref": "op://Vault/tracker/url"}}})
checks = _by_name(doctor.check_settings())
assert checks["settings-secrets"].status == "WARN"
assert "tracker.url" in checks["settings-secrets"].detail
def test_doctor_warns_on_a_section_this_version_does_not_read(root):
_write(root, {"newer": {}})
checks = _by_name(doctor.check_settings())
assert checks["settings-instance"].status == "WARN"
# --- config set -----------------------------------------------------------------
def test_config_set_writes_an_instance_value_and_creates_the_file(root):
result = runner.invoke(app, ["config", "set", "tracker.stale_days", "21"])
assert result.exit_code == 0, result.output
data = _read(root / config.INSTANCE_SETTINGS_FILENAME)
assert data["tracker"] == {"stale_days": 21}
assert data["schema"] == 1
assert data["instance_id"]
assert "created" in _out(result)
assert not (root / config.LOCAL_SETTINGS_FILENAME).exists()
instance_id = data["instance_id"]
result = runner.invoke(app, ["config", "set", "tracker.stale_days", "30"])
assert result.exit_code == 0, result.output
assert _read(root / config.INSTANCE_SETTINGS_FILENAME)["instance_id"] == instance_id
def test_config_set_refuses_a_plain_secret_without_echoing_it(root):
result = runner.invoke(app, ["config", "set", "tracker.token", SECRET_VALUE])
assert result.exit_code == 1
assert SECRET_VALUE not in _squashed(result)
assert not (root / config.INSTANCE_SETTINGS_FILENAME).exists()
def test_config_set_writes_a_secret_reference_into_the_committed_file(root):
result = runner.invoke(app, ["config", "set", "tracker.token", "--ref", "tracker_token"])
assert result.exit_code == 0, result.output
assert _read(root / config.INSTANCE_SETTINGS_FILENAME)["tracker"]["token"] == {"ref": "tracker_token"}
def test_config_set_puts_a_private_value_locally_and_a_reference_in_its_place(root):
result = runner.invoke(app, ["config", "set", "tracker.url", PRIVATE_VALUE])
assert result.exit_code == 0, result.output
assert _read(root / config.LOCAL_SETTINGS_FILENAME)["tracker"]["url"] == PRIVATE_VALUE
assert PRIVATE_VALUE not in _squashed(result)
assert "tracker" not in _read(root / config.INSTANCE_SETTINGS_FILENAME)
result = runner.invoke(app, ["config", "set", "tracker.url", "--ref", "op://Vault/tracker/url"])
assert result.exit_code == 0, result.output
assert "removed from .wikitool-local.json" in _out(result)
assert _read(root / config.INSTANCE_SETTINGS_FILENAME)["tracker"]["url"] == {"ref": "op://Vault/tracker/url"}
assert "tracker" not in _read(root / config.LOCAL_SETTINGS_FILENAME)
def test_config_set_leaves_a_program_field_to_the_user(root):
result = runner.invoke(app, ["config", "set", "tracker.program", "/usr/bin/true"])
assert result.exit_code == 42
assert ".wikitool-local.json" in _out(result)
assert not (root / config.LOCAL_SETTINGS_FILENAME).exists()
@pytest.mark.parametrize("args", [
["tracker.nope", "1"],
["nope.field", "1"],
["nodot", "1"],
["tracker.stale_days", "--ref", "days"],
["tracker.token", "--ref", "Not-A-Name"],
["tracker.stale_days", "many"],
["tracker.stale_days", "0"],
["tracker.stale_days"],
])
def test_config_set_refuses_and_writes_nothing(root, args):
result = runner.invoke(app, ["config", "set", *args])
assert result.exit_code == 1, result.output
assert not (root / config.INSTANCE_SETTINGS_FILENAME).exists()
assert not (root / config.LOCAL_SETTINGS_FILENAME).exists()
def test_config_set_refuses_on_top_of_files_that_do_not_pass(root):
_write(root, {"tracker": {"token": SECRET_VALUE}})
before = (root / config.INSTANCE_SETTINGS_FILENAME).read_text(encoding="utf-8")
result = runner.invoke(app, ["config", "set", "tracker.stale_days", "3"])
assert result.exit_code == 1
assert SECRET_VALUE not in _squashed(result)
assert (root / config.INSTANCE_SETTINGS_FILENAME).read_text(encoding="utf-8") == before
def test_config_set_with_no_section_registered_names_none(root, monkeypatch):
monkeypatch.setattr(config_shapes, "SETTINGS_SECTIONS", ())
result = runner.invoke(app, ["config", "set", "tracker.stale_days", "3"])
assert result.exit_code == 1
assert "none is registered" in _out(result)
# --- the two files in git -------------------------------------------------------
def test_the_repository_ignores_the_local_file_and_tracks_the_committed_one():
root = config._PACKAGE_ROOT
probe = subprocess.run(
["git", "-C", str(root), "check-ignore", "--no-index", "-q", config.LOCAL_SETTINGS_FILENAME],
capture_output=True, check=False,
)
if probe.returncode not in (0, 1):
pytest.skip("no git checkout to ask")
assert probe.returncode == 0
tracked = subprocess.run(
["git", "-C", str(root), "check-ignore", "--no-index", "-q", config.INSTANCE_SETTINGS_FILENAME],
capture_output=True, check=False,
)
assert tracked.returncode == 1
# --- CI: a real op:// value -------------------------------------------------------
def test_live_secret_reference_resolves_from_ci(root, monkeypatch):
"""CI loads `op://CI-CD/...` through 1Password's own action into
`WIKITOOL_SECRET_CI_SMOKE` (.gitea/workflows/ci.yml) and runs this test with
`CHEMENU_LIVE_SECRET_REQUIRE=1`, which turns "no value" into a failure. The
value is never printed - only that a reference resolved to something."""
if not _LIVE_VALUE:
if _LIVE_REQUIRED:
pytest.fail("CHEMENU_LIVE_SECRET_REQUIRE=1 but WIKITOOL_SECRET_CI_SMOKE is empty - "
"the 1Password step did not load it")
pytest.skip("no WIKITOOL_SECRET_CI_SMOKE - set only in CI, by the 1Password step")
monkeypatch.setenv("WIKITOOL_SECRET_CI_SMOKE", _LIVE_VALUE)
_write(root, {"instance_id": "ci", "tracker": {"token": {"ref": "ci_smoke"}}}, {"tracker": {"url": "x"}})
assert settings.load().section("tracker")["token"] == _LIVE_VALUE
checks = _by_name(doctor.check_settings())
assert checks["settings-secrets"].detail == "1 of 1 reference(s) resolvable"
+16
View File
@@ -81,6 +81,22 @@ def resolve(name: str) -> str:
return path return path
def optional(name: str) -> Optional[str]:
"""The program to start for an optional tool - one the preflight records
when it finds it and never asks for (`optional|` in
`tools/prerequisites.txt`). Its recorded path, `name` itself when no file
has been written, and None when the file does not name it or names a path
that has gone: for an optional tool both mean "not installed here", and the
caller says what that costs."""
data = load()
if data is None:
return name
path = data["tools"].get(name)
if not isinstance(path, str) or not path or not os.path.isfile(path):
return None
return path
def git() -> str: def git() -> str:
return resolve("git") return resolve("git")
+49 -3
View File
@@ -537,6 +537,20 @@ foreach ($line in $ManifestLines) {
} }
} }
# Recorded when found, never asked for - see the manifest's own header.
$Optional = @()
foreach ($line in $ManifestLines) {
$parts = $line.Split('|')
if ($parts[0] -ne 'optional') {
continue
}
if ($parts[1] -notmatch '^[a-z0-9]+$') {
Write-ErrorLine "preflight: bad tool name '$($parts[1])' in $Manifest"
exit 1
}
$Optional += $parts[1]
}
function Get-InstallHint { function Get-InstallHint {
param([string]$Tool) param([string]$Tool)
$choco = Get-ManifestField 'tool' $Tool 7 $choco = Get-ManifestField 'tool' $Tool 7
@@ -696,8 +710,8 @@ function Get-SetValue {
# --- --set: every named path has to work, or nothing is written ------------------- # --- --set: every named path has to work, or nothing is written -------------------
foreach ($name in $Sets.Keys) { foreach ($name in $Sets.Keys) {
if ($Tools -notcontains $name) { if ($Tools -notcontains $name -and $Optional -notcontains $name) {
Write-ErrorLine "preflight: --set names '$name', which is not a tool this platform needs: $($Tools -join ' ')" Write-ErrorLine "preflight: --set names '$name', which is not a tool this platform needs: $($Tools -join ' ') (optional: $($Optional -join ' '))"
exit 1 exit 1
} }
} }
@@ -848,6 +862,38 @@ foreach ($tool in $Tools) {
$Found[$tool] = $path $Found[$tool] = $path
} }
# --- optional tools: recorded when found, never a problem when not ---------------
foreach ($tool in $Optional) {
$label = Get-ManifestField 'optional' $tool 3
$given = Get-SetValue $tool
$path = ''
if ($given) {
if (Test-Usable $given) {
$path = $given
} else {
Add-Problem "The path given for $label does not work: $given" (Get-ManifestField 'optional' $tool 4) `
"Check the path - it has to be the $tool executable itself.`nThen run: $Self --set $tool=<full path to $tool>"
$script:BadSet = $true
continue
}
} else {
$previous = Get-RecordedPath $tool
if ($previous -and (Test-Usable $previous)) {
$path = $previous
} else {
$path = [string](Find-OnPath $tool | Select-Object -First 1)
}
}
if (-not $path) {
Add-Report 'ABSENT' $tool '-' 'optional, not installed'
continue
}
$version = Get-VersionOf ([string](Invoke-Native -Path $path -Arguments @('--version')))
Add-Report 'OK' $tool $(if ($version) { $version } else { '-' }) $path
$Found[$tool] = $path
}
# --- install folder length (Windows, long paths off) ------------------------------ # --- install folder length (Windows, long paths off) ------------------------------
if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) { if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) {
@@ -945,7 +991,7 @@ function Write-ToolsFile {
$flag = if ($Complete) { 'true' } else { 'false' } $flag = if ($Complete) { 'true' } else { 'false' }
$text = "{`n `"schema`": 1,`n `"complete`": $flag,`n `"tools`": {" $text = "{`n `"schema`": 1,`n `"complete`": $flag,`n `"tools`": {"
$separator = '' $separator = ''
foreach ($tool in $Tools) { foreach ($tool in @($Tools) + @($Optional)) {
if ($Found.ContainsKey($tool)) { if ($Found.ContainsKey($tool)) {
$text += "$separator`n `"$tool`": `"$(ConvertTo-JsonString $Found[$tool])`"" $text += "$separator`n `"$tool`": `"$(ConvertTo-JsonString $Found[$tool])`""
$separator = ',' $separator = ','
+46 -3
View File
@@ -456,6 +456,14 @@ while IFS='|' read -r kind name minimum platforms rest; do
fi fi
done < "$MANIFEST" done < "$MANIFEST"
# Recorded when found, never asked for - see the manifest's own header.
OPTIONAL=""
while IFS='|' read -r kind name rest; do
[ "$kind" = optional ] || continue
case "$name" in *[!a-z0-9]*|'') echo "preflight: bad tool name '$name' in $MANIFEST" >&2; exit 1 ;; esac
OPTIONAL="$OPTIONAL $name"
done < "$MANIFEST"
# The install command for this machine's package manager, or every known one. # The install command for this machine's package manager, or every known one.
install_hint() { # <tool> install_hint() { # <tool>
choco=$(manifest_field tool "$1" 7) choco=$(manifest_field tool "$1" 7)
@@ -561,9 +569,9 @@ set_value() { # <name> -> the path given with --set, if any
# --- --set: every named path has to work, or nothing is written ------------------- # --- --set: every named path has to work, or nothing is written -------------------
for entry in $(printf '%s\n' "$SETS" | sed -n 's/^\([^=]*\)=.*/\1/p'); do for entry in $(printf '%s\n' "$SETS" | sed -n 's/^\([^=]*\)=.*/\1/p'); do
case " $TOOLS " in case " $TOOLS $OPTIONAL " in
*" $entry "*) ;; *" $entry "*) ;;
*) echo "preflight: --set names '$entry', which is not a tool this platform needs:$TOOLS" >&2 *) echo "preflight: --set names '$entry', which is not a tool this platform needs:$TOOLS (optional:$OPTIONAL)" >&2
exit 1 ;; exit 1 ;;
esac esac
done done
@@ -705,6 +713,41 @@ $SELF --set $tool=<full path to $tool>"
eval "FOUND_$tool=\$native" eval "FOUND_$tool=\$native"
done done
# --- optional tools: recorded when found, never a problem when not ---------------
for tool in $OPTIONAL; do
label=$(manifest_field optional "$tool" 3)
given=$(set_value "$tool")
path=""
if [ -n "$given" ]; then
if usable "$given"; then
path=$given
else
problem "The path given for $label does not work: $given" \
"$(manifest_field optional "$tool" 4)" \
"Check the path - it has to be the $tool executable itself.
Then run: $SELF --set $tool=<full path to $tool>"
BAD_SET=1
continue
fi
else
previous=$(recorded "$tool") || previous=""
if [ -n "$previous" ] && usable "$previous"; then
path=$previous
else
path=$(on_path "$tool" | head -n 1)
fi
fi
if [ -z "$path" ]; then
report ABSENT "$tool" - "optional, not installed"
continue
fi
version=$(version_of "$("$path" --version 2>/dev/null | tr -d '\r')")
native=$(native_path "$path")
report OK "$tool" "${version:--}" "$native"
eval "FOUND_$tool=\$native"
done
# --- install folder length (Windows, long paths off) ------------------------------ # --- install folder length (Windows, long paths off) ------------------------------
if [ "$PLATFORM" = windows ] && ! longpaths_enabled; then if [ "$PLATFORM" = windows ] && ! longpaths_enabled; then
@@ -730,7 +773,7 @@ write_tools_file() { # <true|false>
{ {
printf '{\n "schema": 1,\n "complete": %s,\n "tools": {' "$1" printf '{\n "schema": 1,\n "complete": %s,\n "tools": {' "$1"
separator="" separator=""
for tool in $TOOLS; do for tool in $TOOLS $OPTIONAL; do
eval "value=\${FOUND_$tool:-}" eval "value=\${FOUND_$tool:-}"
[ -n "$value" ] || continue [ -n "$value" ] || continue
printf '%s\n "%s": "%s"' "$separator" "$tool" "$(json_string "$value")" printf '%s\n "%s": "%s"' "$separator" "$tool" "$(json_string "$value")"
+8
View File
@@ -7,8 +7,14 @@
# limit|<name>|<value> # limit|<name>|<value>
# tool|<name>|<minimum version, or ->|<all or windows>|<label>|<why it is needed>|<choco>|<winget>|<brew>|<apt>|<pacman> # tool|<name>|<minimum version, or ->|<all or windows>|<label>|<why it is needed>|<choco>|<winget>|<brew>|<apt>|<pacman>
# #
# optional|<name>|<label>|<what it is for>
#
# A `-` in an install field means that package manager has no entry for this tool. # A `-` in an install field means that package manager has no entry for this tool.
# `name` is the key the tool is recorded under in .wikitool-tools.json. # `name` is the key the tool is recorded under in .wikitool-tools.json.
#
# An `optional` tool is recorded when the preflight finds it and is never asked for: its
# absence is not a problem, and the preflight neither names an install command for it nor
# installs it. What it costs to be without one is said where the tool is used.
# The longest install folder - the directory holding tools/ - that still fits Windows' # The longest install folder - the directory holding tools/ - that still fits Windows'
# MAX_PATH of 259 when long paths are off: 95 + 1 separator + the 160 characters # MAX_PATH of 259 when long paths are off: 95 + 1 separator + the 160 characters
@@ -20,3 +26,5 @@ tool|python|3.11|all|Python|every wikitool command runs on it|choco install pyth
tool|git|-|all|Git|the wiki is a git repository, and publishing commits through git|choco install git|winget install --id Git.Git -e|brew install git|sudo apt install git|sudo pacman -S git tool|git|-|all|Git|the wiki is a git repository, and publishing commits through git|choco install git|winget install --id Git.Git -e|brew install git|sudo apt install git|sudo pacman -S git
tool|rg|-|all|ripgrep (rg)|search and several checks read the wiki through it|choco install ripgrep|winget install --id BurntSushi.ripgrep.MSVC -e|brew install ripgrep|sudo apt install ripgrep|sudo pacman -S ripgrep tool|rg|-|all|ripgrep (rg)|search and several checks read the wiki through it|choco install ripgrep|winget install --id BurntSushi.ripgrep.MSVC -e|brew install ripgrep|sudo apt install ripgrep|sudo pacman -S ripgrep
tool|pwsh|7|windows|PowerShell 7 (pwsh)|the agent harnesses on Windows run their commands in it|choco install powershell-core|winget install --id Microsoft.PowerShell -e|-|-|- tool|pwsh|7|windows|PowerShell 7 (pwsh)|the agent harnesses on Windows run their commands in it|choco install powershell-core|winget install --id Microsoft.PowerShell -e|-|-|-
optional|op|1Password CLI (op)|resolves op:// references in wikitool.json and .wikitool-local.json