feat(accounts): an account names its forge and server, and the command line takes --forge and --server #205

Merged
james.maes merged 4 commits from feat/204-gitea-accounts-and-cli into main 2026-11-11 22:22:17 +00:00
Owner

What

Step 2 of #201, stacked on #203: accounts and the command line can use a Gitea server.

  • Accounts: Account.Forge and Account.Server (left out of accounts.json when default). Fixed once the account is added. The token of an account that is not on GitHub is kept under an entry an older gclo does not look up. The default token (a GitHub token) is never used for another forge.
  • Engine / view models: ForgeEndpoint and AccountListers are the one place that pairs a forge, or an account, with its listers.
  • Command line: --forge github|gitea and --server <address> on sync, repos, orgs, accounts add; accounts list shows where an account lives; GITEA_TOKEN is the default token variable for a Gitea server.
  • App: a workspace's listers come from its account; the wizard checks an edited account's token against the place the account is on. Adding a Gitea account in the wizard is step 3.
  • docs/CLI.md, README, ADR 0010, threat model T16.

Why

Closes #204. The rules this change is built around, each pinned by a test:

  • a saved token goes only to the forge and server it was saved for: an edit cannot move an account, and gclo sync --account takes no --forge or --server;
  • a token in the environment for one forge is never sent to another because no option was given;
  • a gclo from before forges, which reads every account as a GitHub account, finds no token for a Gitea account instead of sending it to GitHub;
  • an account whose stored forge or server cannot be used asks no server anything;
  • a server address that is refused is not repeated in a message (it may have been refused for the password or token in it).

How it was tested

  • ForgeEndpointTests, AccountForgeTests (storage, token entry, the store's refusals, the resolver, the wizard), ForgeCliTests (which place, which token, accounts, sync --account).
  • Gates, started from PowerShell: numbers in the comment below.

Not merged until

1.1.1 is tagged (#200) and #203 is merged. The base of this pull request is the branch of #203.

To confirm against a live server before merging: Gitea accepts the token as the git password with the fixed user name the git client uses.

Checklist

  • Commits are signed and signed off (git commit -S -s)
  • Zero build warnings; format and lint are clean
  • Tests added or updated; the 100% coverage gate still passes
  • Docs updated for user-visible changes; threat model updated if the attack surface changed (docs/CLI.md, README, ADR 0010, T16)
  • No secrets, credentials, or personal data
  • New dependencies are justified below, or none were added (none)
  • Substantial AI assistance, if any, is noted here: written with Claude Code.

🤖 Generated with Claude Code

## What Step 2 of #201, stacked on #203: accounts and the command line can use a Gitea server. - **Accounts:** `Account.Forge` and `Account.Server` (left out of `accounts.json` when default). Fixed once the account is added. The token of an account that is not on GitHub is kept under an entry an older gclo does not look up. The default token (a GitHub token) is never used for another forge. - **Engine / view models:** `ForgeEndpoint` and `AccountListers` are the one place that pairs a forge, or an account, with its listers. - **Command line:** `--forge github|gitea` and `--server <address>` on `sync`, `repos`, `orgs`, `accounts add`; `accounts list` shows where an account lives; `GITEA_TOKEN` is the default token variable for a Gitea server. - **App:** a workspace's listers come from its account; the wizard checks an edited account's token against the place the account is on. Adding a Gitea account in the wizard is step 3. - `docs/CLI.md`, README, ADR 0010, threat model T16. ## Why Closes #204. The rules this change is built around, each pinned by a test: - a saved token goes only to the forge and server it was saved for: an edit cannot move an account, and `gclo sync --account` takes no `--forge` or `--server`; - a token in the environment for one forge is never sent to another because no option was given; - a gclo from before forges, which reads every account as a GitHub account, finds no token for a Gitea account instead of sending it to GitHub; - an account whose stored forge or server cannot be used asks no server anything; - a server address that is refused is not repeated in a message (it may have been refused for the password or token in it). ## How it was tested - `ForgeEndpointTests`, `AccountForgeTests` (storage, token entry, the store's refusals, the resolver, the wizard), `ForgeCliTests` (which place, which token, accounts, `sync --account`). - Gates, started from PowerShell: numbers in the comment below. ## Not merged until 1.1.1 is tagged (#200) and #203 is merged. The base of this pull request is the branch of #203. To confirm against a live server before merging: Gitea accepts the token as the git password with the fixed user name the git client uses. ## Checklist - [x] Commits are signed and signed off (`git commit -S -s`) - [x] Zero build warnings; format and lint are clean - [x] Tests added or updated; the 100% coverage gate still passes - [x] Docs updated for user-visible changes; threat model updated if the attack surface changed (docs/CLI.md, README, ADR 0010, T16) - [x] No secrets, credentials, or personal data - [x] New dependencies are justified below, or none were added (none) - [x] Substantial AI assistance, if any, is noted here: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Gates on 9bcec28, started from PowerShell: build with warnings as errors 0, engine 1,346, CLI 244, line coverage 100% on the three gated assemblies, format clean, UI suite 13 of 13.

🤖 Generated with Claude Code

Gates on 9bcec28, started from PowerShell: build with warnings as errors 0, engine 1,346, CLI 244, line coverage 100% on the three gated assemblies, format clean, UI suite 13 of 13. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Independent review, round 1: merge after fixes. Fixed in 8d855cd.

Finding What was done
An older gclo that rewrites accounts.json drops the forge and server of a Gitea account, which then reads as a GitHub account everywhere Accounts on another forge are kept in accounts-forges.json, which an older version neither reads nor writes. One lock; a change writes only the file of the account it changes. Pinned by a test that rewrites accounts.json the way the older version does.
An unknown forge name made the whole file unreadable The forge is stored as text: an unknown name makes that one account unusable and is written back as it was. Unknown fields are written back too.
The vault entry was not bound to the place The entry of an account on another forge is made from its id, forge and server address as the file states them. A changed address finds no token. T13 restated, with what remains.
A refused address was repeated in accounts list --json and by --forge The listing gives the normalized address or nothing; --forge does not repeat its value.
A stored server in another spelling made every edit fail Places are compared, and what the file says about the place stays to the letter.
Test gaps (a workspace's own listers, the vault put back under the account's entry, the wizard's default token) Tests added; the wizard refuses the default token for another forge in the view model. 12 single-rule mutations of the new code: all caught.

Not changed: the column order of accounts list (documented), and the organization status text (it is shown only for a typed token, which an account's page does not take).

Gates, from PowerShell: build 0 warnings; engine 1,375; CLI 247; 100% line coverage on the three assemblies; format clean; UI 13/13.

A narrow review of this change alone follows before anything merges.

Independent review, round 1: **merge after fixes**. Fixed in `8d855cd`. | Finding | What was done | | --- | --- | | An older gclo that rewrites `accounts.json` drops the forge and server of a Gitea account, which then reads as a GitHub account everywhere | Accounts on another forge are kept in `accounts-forges.json`, which an older version neither reads nor writes. One lock; a change writes only the file of the account it changes. Pinned by a test that rewrites `accounts.json` the way the older version does. | | An unknown forge name made the whole file unreadable | The forge is stored as text: an unknown name makes that one account unusable and is written back as it was. Unknown fields are written back too. | | The vault entry was not bound to the place | The entry of an account on another forge is made from its id, forge and server address as the file states them. A changed address finds no token. T13 restated, with what remains. | | A refused address was repeated in `accounts list --json` and by `--forge` | The listing gives the normalized address or nothing; `--forge` does not repeat its value. | | A stored server in another spelling made every edit fail | Places are compared, and what the file says about the place stays to the letter. | | Test gaps (a workspace's own listers, the vault put back under the account's entry, the wizard's default token) | Tests added; the wizard refuses the default token for another forge in the view model. 12 single-rule mutations of the new code: all caught. | Not changed: the column order of `accounts list` (documented), and the organization status text (it is shown only for a typed token, which an account's page does not take). Gates, from PowerShell: build 0 warnings; engine 1,375; CLI 247; 100% line coverage on the three assemblies; format clean; UI 13/13. A narrow review of this change alone follows before anything merges.
Author
Owner

Review of 8d855cd alone (round 2): merge after fixes. Fixed in 9b82c15.

Finding What was done
A change could write both files, and a failure between the two writes was not undone Whatever else has to be written (a file put back, an account in the wrong file) is written before the change and before the vault is touched; the change itself writes one file
A workspace took its listers and its token from two readings of the file The token is resolved for the place the workspace was opened for
The wizard dropped the fields of a later version The store keeps what the file has, whatever record is saved
A record without a forge in the file for other forges became a GitHub account GitHub is the account that names no forge, in accounts.json; such a record stays where it is and cannot be used
An older gclo can create two accounts of one name, and the first was acted on sync --account, accounts edit and accounts remove refuse a name two accounts share
Documents ADR 0010 and T13 corrected; docs/CLI.md states the name clash

Confirmed sound by the reviewer with the real older build: its write paths leave accounts-forges.json byte-identical, and the new code reads the Gitea account intact with its token.

Left as documented limits (ADR 0010): a later version must keep the JSON type of a field this version knows, and must not name a new field like one of this version's computed properties.

Gates, from PowerShell: build 0 warnings; engine 1,381; CLI 248; 100% line coverage; format clean; UI 13/13. Six single-rule mutations of this change: all caught.

Review of `8d855cd` alone (round 2): **merge after fixes**. Fixed in `9b82c15`. | Finding | What was done | | --- | --- | | A change could write both files, and a failure between the two writes was not undone | Whatever else has to be written (a file put back, an account in the wrong file) is written before the change and before the vault is touched; the change itself writes one file | | A workspace took its listers and its token from two readings of the file | The token is resolved for the place the workspace was opened for | | The wizard dropped the fields of a later version | The store keeps what the file has, whatever record is saved | | A record without a forge in the file for other forges became a GitHub account | GitHub is the account that names no forge, in `accounts.json`; such a record stays where it is and cannot be used | | An older gclo can create two accounts of one name, and the first was acted on | `sync --account`, `accounts edit` and `accounts remove` refuse a name two accounts share | | Documents | ADR 0010 and T13 corrected; `docs/CLI.md` states the name clash | Confirmed sound by the reviewer with the real older build: its write paths leave `accounts-forges.json` byte-identical, and the new code reads the Gitea account intact with its token. Left as documented limits (ADR 0010): a later version must keep the JSON type of a field this version knows, and must not name a new field like one of this version's computed properties. Gates, from PowerShell: build 0 warnings; engine 1,381; CLI 248; 100% line coverage; format clean; UI 13/13. Six single-rule mutations of this change: all caught.
Author
Owner

Review of 9b82c15 alone (round 3): no way was found in it to lose an account, lose or orphan a token, show a change that was not persisted, or hand a token to another place. The four findings of round 2 were rerun by the reviewer: all closed.

What it did find: docs/CLI.md gave exit code 1 for a name two accounts share; it is 2. Corrected in 04b458d, and the test states the code. ADR 0010 now says that a record without a forge in the file for other forges is written back with an empty forge name. The command line cannot end a name clash by itself: filed as its own issue.

After three rounds this pull request is ready as far as review goes. It waits for the tag of 1.1.1 (#200), for #203, and for the confirmation against a live Gitea server.

Review of `9b82c15` alone (round 3): no way was found in it to lose an account, lose or orphan a token, show a change that was not persisted, or hand a token to another place. The four findings of round 2 were rerun by the reviewer: all closed. What it did find: `docs/CLI.md` gave exit code 1 for a name two accounts share; it is 2. Corrected in `04b458d`, and the test states the code. ADR 0010 now says that a record without a forge in the file for other forges is written back with an empty forge name. The command line cannot end a name clash by itself: filed as its own issue. After three rounds this pull request is ready as far as review goes. It waits for the tag of 1.1.1 (#200), for #203, and for the confirmation against a live Gitea server.
james.maes changed target branch from feat/202-gitea-listers to main 2026-11-11 22:22:13 +00:00
james.maes added 4 commits 2026-11-11 22:22:13 +00:00
Step 2 of Gitea support (#201): accounts and the command line can use a Gitea
server. The desktop app syncs such an account; its wizard adds GitHub accounts
only, for now.

- Account.Forge and Account.Server, left out of accounts.json when they are
  the default. They are set when an account is added and cannot be changed by
  an edit: a token that was saved for one server is not sent to another
  because an address was edited.
- The token of an account that is not on GitHub is kept under another entry
  than the account's id. A gclo from before forges reads every account as a
  GitHub account; it finds no token there, instead of sending a Gitea token to
  GitHub.
- The default token is a GitHub token: it is never handed to an account on
  another forge, and such an account cannot be saved as using it.
- ForgeEndpoint (engine) and AccountListers (view models) are the one place
  that pairs a forge, or an account, with its listers. An account whose stored
  forge or server cannot be used asks no server anything.
- Command line: --forge github|gitea and --server on sync, repos, orgs and
  accounts add; accounts list shows where an account lives; accounts edit and
  sync --account take no other place. The default token variable is per forge
  (GITHUB_TOKEN, GITEA_TOKEN), and one is never used for the other. Exit codes
  are by the kind of failure, whatever the forge.
- The app builds a workspace's listers from its account, and checks an edited
  account's token against the place the account is on.
- A refused server address is not repeated in a message.
- docs/CLI.md, README, ADR 0010, threat model T16.

Closes #204

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
From the review of #205.

- A gclo from before forges reads every account in accounts.json as a GitHub
  account and writes the file back with the fields it knows. It dropped the
  forge and the server of a Gitea account, which then read as a GitHub account
  in every version: its token would be asked for again and sent to GitHub.
  Accounts on another forge are now kept in accounts-forges.json, which such a
  version neither reads nor writes. Both files are changed under the one lock;
  a change writes only the file of the account it changes.
- The kind of forge is stored as text. A name this version does not know (a
  forge of a later version) makes that one account unusable, not the file
  unreadable, and is written back as it was. Fields this version does not know
  are written back as they were read.
- The vault entry of an account on another forge is made from its id, its
  forge and its server address as the file states them. An account whose
  address was changed in the file finds no token for the new address, and a
  record that names another account's id finds none of that account's.
- An account whose stored address is spelled another way can be edited; what
  the file says about the place stays as it is.
- accounts list --json gives the address a token is sent to, and nothing for
  an address that cannot be used; --forge does not repeat what it was given.
- The wizard refuses the default token for an account on another forge in the
  view model, not only by hiding the choice.
- Tests: an older version's rewrite (as it does it), the two files, the
  unknown forge and fields, the binding of a token to its place, the vault
  being put back under the account's entry, a workspace's own listers.
- docs/CLI.md, ADR 0010, threat model T13 and T16, CLAUDE.md.

Refs #204

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
From the review of 8d855cd (#205, second round).

- A change could write both account files (one to put a missing file back or
  to move an account that was in the wrong file, one for the change), and a
  failure between the two was not undone: a token put back for an account
  that was gone from disk, or an account on disk whose token was taken back.
  Whatever else has to be written is now written before the change, before
  the vault is touched; when that fails the change fails with nothing
  changed. The change itself writes one file, as it always did.
- A workspace made its listers from the record it was opened with and took
  its token from the record as the store has it now. A file changed in
  between could bring the token of one place to the listers of another. The
  token is now resolved for the place the workspace was opened for.
- What a later version put on an account is kept whatever record is saved:
  the wizard builds its record anew and dropped it.
- GitHub is the account that names no forge, and only that one. A record in
  the file for other forges that names none is not read as a GitHub account
  (it would get GitHub's listers and the default token): it stays there and
  cannot be used.
- Two accounts of one name (an older gclo can add a GitHub account with the
  name of one it does not see): sync --account, accounts edit and accounts
  remove refuse the name instead of acting on the first of the two.
- Tests for each, and for the order of the two writes. docs/CLI.md, ADR 0010,
  T13.

Refs #204

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
From the third review round of #205, which found no defect in the code of
9b82c15. docs/CLI.md gave the wrong exit code; the test now states it. ADR
0010 says that a record without a forge in the file for other forges is
written back with an empty forge name.

Refs #204

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
james.maes merged commit 390ca13b57 into main 2026-11-11 22:22:17 +00:00
Sign in to join this conversation.