feat(accounts): an account names its forge and server, and the command line takes --forge and --server
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>
This commit is contained in:
1 parent
bce029dc16
commit
9bcec28285
26 files changed
+1746
-135
No files matched your search
@@ -183,10 +183,24 @@ gclo sync --org contoso --target ~/src/contoso --json |
|
||||
| 0 | Everything succeeded. |
|
||||
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
|
||||
| 2 | Fatal: bad arguments, missing or empty token, or organization not found. |
|
||||
| 3 | GitHub rejected the token or refused it access: fix the token, do not retry as is. |
|
||||
| 4 | A rate limit, a network failure, a timeout, or an error on GitHub's side: retry later. |
|
||||
| 3 | GitHub (or the Gitea server) rejected the token or refused it access: fix the token, do not retry as is. |
|
||||
| 4 | A rate limit, a network failure, a timeout, or an error on the server's side: retry later. |
|
||||
| 70 | An unexpected error inside gclo; details are in the activity log. |
|
||||
|
||||
**A Gitea server instead of GitHub** (also Forgejo, which speaks the same API): name the server with `--server`. The token comes from `GITEA_TOKEN` then, never from `GITHUB_TOKEN`, and it is sent to that server only (HTTPS; redirects are not followed).
|
||||
|
||||
```powershell
|
||||
$env:GITEA_TOKEN = (Get-Secret -Name GiteaToken -AsPlainText)
|
||||
gclo orgs --server https://git.example.com
|
||||
gclo sync --server https://git.example.com --org platform --target D:\mirror\platform
|
||||
|
||||
# As a saved account; the desktop app syncs it too
|
||||
Get-Secret -Name GiteaToken -AsPlainText | gclo accounts add --name platform --org platform --target D:\mirror\platform --server https://git.example.com --token-stdin
|
||||
gclo sync --account platform
|
||||
```
|
||||
|
||||
An account's forge and server are fixed once it is added (its token was saved for that place); the app's wizard adds GitHub accounts, a Gitea account is added from the command line for now.
|
||||
|
||||
Full reference — all options, output streams, Ctrl+C behavior, and more examples: [docs/CLI.md](docs/CLI.md).
|
||||
|
||||
## How it works
|
||||
|
||||
+95
-29
@@ -2,8 +2,8 @@
|
||||
|
||||
`gclo` is a scriptable command-line head over the same engine (`gclo.Engine`) that
|
||||
powers the gclo desktop app. It clones every repository of a GitHub organization
|
||||
(or user account) into a local folder, and fast-forwards the ones that already
|
||||
exist there.
|
||||
(or user account), or of one on a Gitea server, into a local folder, and
|
||||
fast-forwards the ones that already exist there.
|
||||
|
||||
## Building and running
|
||||
|
||||
@@ -25,15 +25,20 @@ and runs on Linux/macOS hosts with the .NET 10 SDK.
|
||||
|
||||
```
|
||||
gclo sync --org <name> --target <folder> [--parallel N] [--sanitize-paths]
|
||||
[--forge github|gitea] [--server ADDRESS]
|
||||
[--include GLOB]... [--exclude GLOB]... [--skip-archived] [--dry-run]
|
||||
[--token-env VAR | --token-file PATH | --token-stdin]
|
||||
[--json | --json-lines] [--quiet]
|
||||
gclo sync --account <name> [same options — each one overrides the account value]
|
||||
gclo repos --org <name> [--include GLOB]... [--exclude GLOB]... [--skip-archived]
|
||||
gclo sync --account <name> [same options — each one overrides the account value;
|
||||
not --forge or --server: the account says where it lives]
|
||||
gclo repos --org <name> [--forge github|gitea] [--server ADDRESS]
|
||||
[--include GLOB]... [--exclude GLOB]... [--skip-archived]
|
||||
[--token-env VAR | --token-file PATH | --token-stdin] [--json]
|
||||
gclo orgs [--forge github|gitea] [--server ADDRESS]
|
||||
[--token-env VAR | --token-file PATH | --token-stdin] [--json]
|
||||
gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json]
|
||||
gclo accounts [list] [--json]
|
||||
gclo accounts add --name <name> --org <name> --target <folder> [--parallel N]
|
||||
[--forge github|gitea] [--server ADDRESS]
|
||||
[--org-subfolder] [--description TEXT] [token option]
|
||||
gclo accounts edit --name <name> [--rename NEW] [--org NAME] [--target FOLDER]
|
||||
[--parallel N] [--org-subfolder | --no-org-subfolder] [--description TEXT]
|
||||
@@ -262,14 +267,54 @@ without the `read:org` scope) still prints its own account login — you can pas
|
||||
any organization name to `gclo sync` manually. A rate limit or temporary
|
||||
throttle is reported as such (exit code 4) rather than shrinking the list.
|
||||
|
||||
### GitHub, or a Gitea server: `--forge` and `--server`
|
||||
|
||||
Without either option a command talks to GitHub. For an organization on a Gitea
|
||||
server (or a Forgejo server, which speaks the same API), name the server:
|
||||
|
||||
```powershell
|
||||
$env:GITEA_TOKEN = (Get-Secret -Name GiteaToken -AsPlainText)
|
||||
gclo orgs --server https://git.example.com
|
||||
gclo repos --server https://git.example.com --org platform
|
||||
gclo sync --server https://git.example.com --org platform --target D:\mirror\platform
|
||||
```
|
||||
|
||||
- `--server <address>` is the server's own address. `--forge gitea` goes with it
|
||||
and may be left out; `--forge github` is the default and takes no server.
|
||||
- **HTTPS only.** `http://` is refused, and nothing turns certificate checks
|
||||
off. A server with a certificate from a private authority works once Windows
|
||||
(or the system gclo runs on) trusts that authority.
|
||||
- **The token goes to that server and nowhere else.** gclo does not follow a
|
||||
redirect the server answers with, and clones from the address you gave, not
|
||||
from an address the server names. If the server answers with a redirect, the
|
||||
address is not the one the server itself uses, or the organization was
|
||||
renamed; the message says so.
|
||||
- **Each forge has its own default token variable**: `GITHUB_TOKEN` for GitHub,
|
||||
`GITEA_TOKEN` for a Gitea server. One is never used for the other, so a GitHub
|
||||
token that happens to be in the environment is not sent to a Gitea server.
|
||||
`--token-env`, `--token-file` and `--token-stdin` work as everywhere.
|
||||
- A Gitea access token needs to be allowed to read repositories, organizations
|
||||
and the user (`read:repository`, `read:organization`, `read:user`). Without
|
||||
the second, `gclo orgs` prints the account alone.
|
||||
- A server that is reachable only through a sign-in page or a single-sign-on
|
||||
proxy does not work: gclo reports that the address does not answer like a
|
||||
Gitea server.
|
||||
|
||||
## Accounts
|
||||
|
||||
An *account* is a saved sync profile: a name, an organization, a target root
|
||||
folder, a parallelism setting, an org-subfolder preference, an optional
|
||||
description, and a token stored in **Windows Credential Manager** (under
|
||||
`gclo:account:<id>`, or `gclo:<scope>:account:<id>` when `GCLO_DATA_DIR` points
|
||||
the data elsewhere; the metadata file,
|
||||
`%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains the token).
|
||||
An *account* is a saved sync profile: a name, an organization, where it lives
|
||||
(GitHub, or a Gitea server and its address), a target root folder, a parallelism
|
||||
setting, an org-subfolder preference, an optional description, and a token
|
||||
stored in **Windows Credential Manager** (under `gclo:account:<id>`, or
|
||||
`gclo:<scope>:account:<id>` when `GCLO_DATA_DIR` points the data elsewhere; the
|
||||
metadata file, `%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains
|
||||
the token).
|
||||
|
||||
The token of an account on a Gitea server is kept under a different entry
|
||||
name than `<id>` (one derived from it). A version of gclo from before 1.2 knows
|
||||
only GitHub and reads every account as a GitHub account; it does not find that
|
||||
entry, so it reports the account as having no token instead of sending a Gitea
|
||||
token to GitHub.
|
||||
Accounts are shared between the CLI and the desktop app: create them with
|
||||
`gclo accounts add` on a headless mirror server, or in the app's account
|
||||
wizard, and use them from either.
|
||||
@@ -291,25 +336,29 @@ few seconds and then exits with code 4; run it again.
|
||||
### `gclo accounts` / `gclo accounts list`
|
||||
|
||||
Lists the saved accounts, one per line, in aligned columns: name, organization,
|
||||
target root, token source (`own` or `default`), and last sync time (local time,
|
||||
`never` when the account has not completed a sync yet):
|
||||
where it lives (`GitHub`, or the host of a Gitea server), target root, token
|
||||
source (`own` or `default`), and last sync time (local time, `never` when the
|
||||
account has not completed a sync yet):
|
||||
|
||||
```
|
||||
work contoso C:\src\contoso own 2026-07-04 09:12
|
||||
personal octocat D:\mirror default never
|
||||
work contoso GitHub C:\src\contoso own 2026-07-04 09:12
|
||||
personal octocat GitHub D:\mirror default never
|
||||
platform platform git.example.com D:\platform own never
|
||||
```
|
||||
|
||||
With `--json` it prints a single-line JSON array with everything a script needs
|
||||
to reason about a run; `lastSync` is the UTC timestamp of the last completed
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
|
||||
`tokenSource` is `own` or `default`:
|
||||
`tokenSource` is `own` or `default`; `forge` is `github` or `gitea`, and
|
||||
`server` is the address of a Gitea account's server (`null` for GitHub). The
|
||||
last two fields are new in 1.2:
|
||||
|
||||
```json
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
|
||||
```
|
||||
|
||||
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
|
||||
for an `own` account; a `default` account has no entry of its own and resolves
|
||||
for an `own` account on GitHub; a `default` account has no entry of its own and resolves
|
||||
to the default token saved in the desktop app's Settings whenever it syncs.
|
||||
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
|
||||
hint goes to stderr; the exit code is still 0.
|
||||
@@ -328,15 +377,28 @@ account is used later from other places; a target that cannot be a folder
|
||||
`%NAME%`) is refused. `--description=` (or `--description ""`) is an empty
|
||||
description. The token comes from a
|
||||
token option exactly as for `gclo sync` (default: the `GITHUB_TOKEN` environment
|
||||
variable) and is written to Windows Credential Manager — never put it on the
|
||||
command line. A name that another account already uses (case-insensitive)
|
||||
exits with code 2.
|
||||
variable, or `GITEA_TOKEN` for a Gitea server) and is written to Windows
|
||||
Credential Manager — never put it on the command line. A name that another
|
||||
account already uses (case-insensitive) exits with code 2.
|
||||
|
||||
For an account on a Gitea server, add `--server` (see "GitHub, or a Gitea
|
||||
server"):
|
||||
|
||||
```powershell
|
||||
Get-Secret -Name GiteaToken -AsPlainText | gclo accounts add --name platform --org platform --target D:\platform --server https://git.example.com --token-stdin
|
||||
```
|
||||
|
||||
The forge and the server are **fixed once the account is added**. The token
|
||||
was saved for that place, and editing an address must not send it somewhere
|
||||
else: to move an account, remove it and add it again.
|
||||
|
||||
With `--use-default-token` instead of a token option, the account holds no
|
||||
token of its own: it resolves to the default token saved in the desktop app's
|
||||
Settings every time it syncs, so replacing that token in Settings updates every
|
||||
such account at once. The command fails (exit 2) when no default token is saved,
|
||||
because the account could never sync; the CLI cannot set the default token.
|
||||
The default token is a GitHub token: `--use-default-token` is for GitHub
|
||||
accounts only, and an account on a Gitea server always has a token of its own.
|
||||
|
||||
```powershell
|
||||
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
|
||||
@@ -355,7 +417,8 @@ Without a token option the token (and its source) is left untouched. A
|
||||
`--token-*` option gives the account a token of its own (also switching a
|
||||
`default` account to `own`); `--use-default-token` switches it to the default
|
||||
token and removes its own entry from Credential Manager. Editing with nothing
|
||||
to change is a usage error.
|
||||
to change is a usage error. `--forge` and `--server` are refused: where an
|
||||
account lives cannot be changed (remove it and add it again).
|
||||
|
||||
### `gclo accounts remove`
|
||||
|
||||
@@ -368,8 +431,9 @@ on disk are not touched.
|
||||
|
||||
### `gclo sync --account <name>`
|
||||
|
||||
Runs a sync with the account's settings and its stored token — no `--org`,
|
||||
`--target`, or token option needed:
|
||||
Runs a sync with the account's settings and its stored token, from the forge
|
||||
and server the account was added for — no `--org`, `--target`, or token option
|
||||
needed (`--forge` and `--server` are not accepted with `--account`):
|
||||
|
||||
```powershell
|
||||
gclo sync --account work
|
||||
@@ -402,7 +466,8 @@ Notes:
|
||||
|
||||
## Providing the token
|
||||
|
||||
gclo needs a GitHub Personal Access Token for both the API and the git transport.
|
||||
gclo needs a token for both the API and the git transport: a GitHub Personal
|
||||
Access Token, or an access token of the Gitea server.
|
||||
|
||||
> **Why is there no `--token <value>` option?**
|
||||
> Command-line arguments are visible to every other process on the machine —
|
||||
@@ -413,7 +478,7 @@ gclo needs a GitHub Personal Access Token for both the API and the git transport
|
||||
|
||||
| Option | Behavior |
|
||||
| --- | --- |
|
||||
| *(none)* | Reads the `GITHUB_TOKEN` environment variable. |
|
||||
| *(none)* | Reads the `GITHUB_TOKEN` environment variable; for a Gitea server (`--server`) the `GITEA_TOKEN` variable instead. Neither is used for the other forge. |
|
||||
| `--token-env VAR` | Reads environment variable `VAR`. |
|
||||
| `--token-file PATH` | Reads the first non-blank line of `PATH`, trimmed. |
|
||||
| `--token-stdin` | Reads one line from standard input — made for piping from a secret store. |
|
||||
@@ -422,8 +487,9 @@ The options are mutually exclusive. A missing or empty token prints an error to
|
||||
stderr and exits with code 2.
|
||||
|
||||
With `gclo sync --account`, the default source is the account's token in
|
||||
Windows Credential Manager instead of `GITHUB_TOKEN`; any token option above
|
||||
still wins for that run (the stored token is left untouched).
|
||||
Windows Credential Manager instead of an environment variable; any token option
|
||||
above still wins for that run (the stored token is left untouched), and is sent
|
||||
to the place the account lives.
|
||||
|
||||
## Activity log
|
||||
|
||||
@@ -452,8 +518,8 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
|
||||
| 0 | Everything succeeded (or `--dry-run` / a listing completed). | — |
|
||||
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). `--json`'s `wasCanceled` tells the two apart. | Inspect the failures; retry them. |
|
||||
| 2 | Fatal: bad arguments, missing/empty token, organization not found, unknown account, missing account token, unusable target folder, or an account command on a non-Windows OS. | Fix the invocation. |
|
||||
| 3 | GitHub rejected the token (401), or refused it access (403: the token may not see the organization, or the organization requires SAML single sign-on and the token is not authorized for it). The message carries GitHub's reason when it gave one. | Fix the token or its access; do not retry as is. |
|
||||
| 4 | Not your doing: GitHub's primary or secondary rate limit (403 or 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, an error on GitHub's side (HTTP 5xx), or the accounts file being changed by another gclo process at that moment. | Retry later (for a rate limit, the message says when). |
|
||||
| 3 | The forge (GitHub, or the Gitea server) rejected the token (401), or refused it access (403: the token may not see the organization, the organization requires SAML single sign-on and the token is not authorized for it, or a Gitea token lacks a scope). The message carries the server's reason when it gave one. | Fix the token or its access; do not retry as is. |
|
||||
| 4 | Not your doing: a rate limit (GitHub's primary or secondary one, 403 or 429; a Gitea server's 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, an error on the server's side (HTTP 5xx), or the accounts file being changed by another gclo process at that moment. | Retry later (for a rate limit, the message says when). |
|
||||
| 70 | An unexpected error inside gclo. The exception type is printed and the full details are in the activity log. | Please report it. |
|
||||
|
||||
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
|
||||
|
||||
@@ -69,6 +69,30 @@ were the two lister implementations and the failure type they throw.
|
||||
whatever the name is; the account path is tried then as well, so such a token
|
||||
still lists its own account.
|
||||
|
||||
### Accounts and the command line (#204)
|
||||
|
||||
- **An account names its forge and, for Gitea, its server.** Both are left out of
|
||||
`accounts.json` when they are the default, so files written before stay as they
|
||||
are and a GitHub account written now reads the same in an older gclo.
|
||||
- **A token stays with the place it was saved for.** The forge and the server of
|
||||
an account are set when it is added and cannot be changed by an edit, in the
|
||||
app or on the command line: to move an account, it is removed and added again,
|
||||
and its token is entered for the new place. `gclo sync --account` takes no
|
||||
`--forge` or `--server`.
|
||||
- **An older gclo must not send a Gitea token to GitHub.** A version from before
|
||||
forges reads every account as a GitHub account and looks its token up under the
|
||||
account's id. The token of an account that is not on GitHub is therefore kept
|
||||
under another entry (the id combined with a fixed value): to the older version
|
||||
the account has no token, and it says so.
|
||||
- **Tokens do not cross forges by default.** The default token saved in Settings
|
||||
is a GitHub token: an account on another forge has a token of its own, and is
|
||||
never handed the default one, whatever its record says. On the command line
|
||||
each forge has its own default variable (`GITHUB_TOKEN`, `GITEA_TOKEN`).
|
||||
- **One place pairs an account with its listers** (`AccountListers`, over
|
||||
`ForgeEndpoint`), used by the app and the command line. An account whose stored
|
||||
forge or server cannot be used (a file edited by hand) gets listers that ask no
|
||||
server anything and fail with the reason.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A Gitea client package.** One more dependency to track and to trust with the
|
||||
@@ -79,6 +103,9 @@ were the two lister implementations and the failure type they throw.
|
||||
address also makes such a server work.
|
||||
- **Following redirects on the same host.** Simpler to refuse them all than to
|
||||
decide which are the same host.
|
||||
- **Letting an edit change an account's server**, with the token entered again.
|
||||
The rule "the token is asked for again" would have to hold in the wizard, the
|
||||
command line and the store; "it cannot be changed" holds in the store alone.
|
||||
- **Allowing `http://` for a server on a local network, or a switch to accept any
|
||||
certificate.** Both put the token on the wire, or in the hands of whoever answers,
|
||||
in exactly the setups where nobody is watching. The supported route for a private
|
||||
|
||||
@@ -27,7 +27,7 @@ about the user's accounts or repositories. It sends nothing anywhere else.
|
||||
| gclo app / CLI | `gclo.exe` (WinUI 3) and the CLI `gclo.exe`, same engine | Trusted; runs as the user |
|
||||
| GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT |
|
||||
| Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) |
|
||||
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). At this revision in the engine library only: accounts, the CLI and the app do not use it yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
|
||||
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). Used by accounts and the command line (#204); the desktop app's wizard cannot add such an account yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
|
||||
| Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account |
|
||||
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
|
||||
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
|
||||
@@ -100,7 +100,7 @@ flowchart LR
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
|
||||
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
|
||||
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; the token of an account that is not on GitHub is kept under an entry name that a gclo from before forges does not look up, so that version reports "no token" instead of sending a Gitea token to GitHub; an account whose stored forge or server cannot be used asks no server anything. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
|
||||
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
|
||||
|
||||
## 7. Accepted risks
|
||||
@@ -133,3 +133,4 @@ flowchart LR
|
||||
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Corrections from the release reviews (#183): the header names the version reviewed; section 1 says that an installed build contacts `git.kof22.com` for updates; the accepted risks no longer say the binaries are unsigned (they are signed, ADR 0008) and name T1 and the Scoop part of T2 instead; T2 and T11 say what ADR 0009 changed (the Scoop manifest and the file it names come from one server; the copy of release assets to that server is manual and outside the attested pipeline). |
|
||||
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T4: a proxy named in a folder's own git configuration, and its own `http.followRedirects`, are refused like a URL rewrite, so the folder does not choose the server a fetch is made through or sent on to (the token was not exposed by the proxy: TLS is end to end). T3: a repository whose name is git's own folder name, in any spelling Windows opens as that folder, fails its row in the sync on every platform, not only on Windows, and is refused by the WSL clone (#184). |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); a Gitea account's token is stored where an older gclo does not look. |
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The command line and forges: which place a run names, which token goes there, and
|
||||
/// what an account may and may not be changed to. The rules that matter most are
|
||||
/// about tokens: the one in the environment for one forge is never used for another,
|
||||
/// and an account's token goes only where the account was added for.
|
||||
/// </summary>
|
||||
public sealed class ForgeCliTests : IDisposable
|
||||
{
|
||||
private const string Server = "https://git.example.test/";
|
||||
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
private readonly string? _gitHubBefore = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
|
||||
|
||||
private readonly string? _giteaBefore = Environment.GetEnvironmentVariable(TokenOptions.GiteaVariable);
|
||||
|
||||
private readonly List<ForgeEndpoint> _asked = [];
|
||||
|
||||
private readonly FakeRepoLister _repos = new() { Result = [new RepoDescriptor("tool", "https://x/tool.git", "main", false)] };
|
||||
|
||||
private readonly FakeOrgLister _orgs = new() { Result = ["me", "acme"] };
|
||||
|
||||
private AccountsStore? _store;
|
||||
|
||||
|
||||
|
||||
public ForgeCliTests() => Tokens(gitHub: "github-token", gitea: "gitea-token");
|
||||
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, _gitHubBefore);
|
||||
Environment.SetEnvironmentVariable(TokenOptions.GiteaVariable, _giteaBefore);
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch(IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static void Tokens(string? gitHub, string? gitea)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, gitHub);
|
||||
Environment.SetEnvironmentVariable(TokenOptions.GiteaVariable, gitea);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
|
||||
|
||||
|
||||
|
||||
private Func<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
|
||||
|
||||
|
||||
|
||||
private IRepositoryLister ReposOf(ForgeEndpoint endpoint)
|
||||
{
|
||||
_asked.Add(endpoint);
|
||||
return _repos;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private IOrganizationLister OrgsOf(ForgeEndpoint endpoint)
|
||||
{
|
||||
_asked.Add(endpoint);
|
||||
return _orgs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private Task<int> Repos(params string[] args) => ReposCommand.RunAsync(args, ReposOf, new NullLog(), CancellationToken.None);
|
||||
|
||||
|
||||
|
||||
private Task<int> Orgs(params string[] args) => OrgsCommand.RunAsync(args, OrgsOf, new NullLog(), CancellationToken.None);
|
||||
|
||||
|
||||
|
||||
private Task<int> Sync(params string[] args)
|
||||
=> SyncCommand.RunAsync(args, ReposOf, new FakeGit(), Open, new NullLog(), CancellationToken.None);
|
||||
|
||||
|
||||
|
||||
private int Accounts(params string[] args) => AccountsCommand.Run(args, Open, new NullLog());
|
||||
|
||||
|
||||
|
||||
private Account AddGiteaAccount(string name = "forge")
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
Assert.Equal(0, Accounts("add", "--name", name, "--org", "acme", "--target", Path.Combine(_dir, "target"), "--server", "git.example.test"));
|
||||
return Store.FindByName(name)!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- which place, which token
|
||||
|
||||
[Theory]
|
||||
[InlineData("--forge", "gitea", "--server", "git.example.test")]
|
||||
[InlineData("--server", Server)] // a server alone means a Gitea server
|
||||
[InlineData("--forge", "GITEA", "--server", "https://GIT.example.test")]
|
||||
public async Task AGiteaRun_AsksThatServer_WithTheGiteaToken_NeverTheGitHubOne(params string[] forge)
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Repos(["--org", "acme", .. forge]);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
ForgeEndpoint asked = Assert.Single(_asked);
|
||||
Assert.Equal(ForgeKind.Gitea, asked.Kind);
|
||||
Assert.Equal(Server, asked.Server!.AbsoluteUri);
|
||||
Assert.Equal("gitea-token", _repos.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData]
|
||||
[InlineData("--forge", "github")]
|
||||
[InlineData("--forge", "GitHub")]
|
||||
public async Task AGitHubRun_AsksGitHub_WithTheGitHubToken_NeverTheGiteaOne(params string[] forge)
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Repos(["--org", "acme", .. forge]);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Same(ForgeEndpoint.GitHub, Assert.Single(_asked));
|
||||
Assert.Equal("github-token", _repos.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task WithNoGiteaTokenInTheEnvironment_AGiteaRunFails_AndDoesNotFallBackToTheGitHubToken()
|
||||
{
|
||||
Tokens(gitHub: "github-token", gitea: null);
|
||||
|
||||
CliErrorException repos = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme", "--server", Server));
|
||||
CliErrorException orgs = await Assert.ThrowsAsync<CliErrorException>(() => Orgs("--server", Server));
|
||||
CliErrorException sync = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Sync("--org", "acme", "--target", _dir, "--server", Server, "--dry-run"));
|
||||
|
||||
Assert.All([repos, orgs, sync], error => Assert.Contains("'GITEA_TOKEN' is not set", error.Message, StringComparison.Ordinal));
|
||||
Assert.Null(_repos.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task WithNoGitHubTokenInTheEnvironment_AGitHubRunFails_AndDoesNotFallBackToTheGiteaToken()
|
||||
{
|
||||
Tokens(gitHub: null, gitea: "gitea-token");
|
||||
|
||||
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme"));
|
||||
|
||||
Assert.Contains("'GITHUB_TOKEN' is not set", error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_repos.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("Unknown forge 'gitlab'. Use github or gitea.", "--forge", "gitlab")]
|
||||
[InlineData("A Gitea account needs the address of its server.", "--forge", "gitea")]
|
||||
[InlineData("GitHub has no server address to choose: it is always github.com.", "--forge", "github", "--server", Server)]
|
||||
[InlineData("The server address cannot be used: it has to begin with https://.", "--server", "http://git.example.test")]
|
||||
[InlineData(
|
||||
"The server address cannot be used: it contains a user name or a password; the token is entered separately.",
|
||||
"--server", "https://someone:s3cret-token@git.example.test")] // not repeated: the secret is in it
|
||||
public async Task WhatNamesNoPlace_IsAUsageError_BeforeAnythingIsAsked(string said, params string[] forge)
|
||||
{
|
||||
CliUsageException repos = await Assert.ThrowsAsync<CliUsageException>(() => Repos(["--org", "acme", .. forge]));
|
||||
CliUsageException orgs = await Assert.ThrowsAsync<CliUsageException>(() => Orgs(forge));
|
||||
CliUsageException sync = await Assert.ThrowsAsync<CliUsageException>(() => Sync(["--org", "acme", "--target", _dir, .. forge]));
|
||||
CliUsageException add = Assert.Throws<CliUsageException>(
|
||||
() => Accounts(["add", "--name", "n", "--org", "acme", "--target", _dir, .. forge]));
|
||||
|
||||
Assert.All([repos, orgs, sync, add], error => Assert.Equal(said, error.Message));
|
||||
Assert.Empty(_asked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Orgs_OfAGiteaServer_AreAskedThere_WithTheGiteaToken()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Orgs("--server", Server);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal(ForgeKind.Gitea, Assert.Single(_asked).Kind);
|
||||
Assert.Contains("acme", console.Out, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_OfAGiteaServer_ListsThere()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Sync("--org", "acme", "--target", Path.Combine(_dir, "t"), "--forge", "gitea", "--server", Server, "--dry-run");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal(Server, Assert.Single(_asked).Server!.AbsoluteUri);
|
||||
Assert.Equal("gitea-token", _repos.LastToken);
|
||||
Assert.Contains("tool", console.Out, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(ForgeAccessKind.Unauthorized, ExitCodes.Auth)]
|
||||
[InlineData(ForgeAccessKind.Forbidden, ExitCodes.Auth)]
|
||||
[InlineData(ForgeAccessKind.RateLimited, ExitCodes.Transient)]
|
||||
[InlineData(ForgeAccessKind.Unavailable, ExitCodes.Transient)]
|
||||
[InlineData(ForgeAccessKind.NotFound, ExitCodes.Fatal)]
|
||||
public async Task AFailureOfAnyForge_HasTheExitCodeOfItsKind(ForgeAccessKind kind, int exitCode)
|
||||
{
|
||||
_repos.Throw = new ForgeAccessException(kind, "what the server said");
|
||||
|
||||
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme", "--server", Server));
|
||||
|
||||
Assert.Equal(exitCode, error.ExitCode);
|
||||
Assert.Equal(exitCode, ExitCodes.ForAccess(kind));
|
||||
Assert.Equal("what the server said", error.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void EachForge_HasItsOwnDefaultTokenVariable()
|
||||
{
|
||||
Assert.Equal("GITHUB_TOKEN", TokenOptions.DefaultVariableFor(ForgeKind.GitHub));
|
||||
Assert.Equal("GITEA_TOKEN", TokenOptions.DefaultVariableFor(ForgeKind.Gitea));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- accounts
|
||||
|
||||
[Fact]
|
||||
public void AccountsAdd_ForAGiteaServer_SavesTheForgeAndTheNormalizedServer_AndTheGiteaTokenInItsOwnEntry()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = Accounts("add", "--name", "forge", "--org", "acme", "--target", Path.Combine(_dir, "target"), "--server", "git.example.test");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Account 'forge' added on git.example.test.", console.Out, StringComparison.Ordinal);
|
||||
Account account = Store.FindByName("forge")!;
|
||||
Assert.Equal(ForgeKind.Gitea, account.Forge);
|
||||
Assert.Equal(Server, account.Server);
|
||||
Assert.Equal("gitea-token", _vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(account.Id)); // where a gclo that knows only GitHub would look
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsAdd_ForGitHub_IsAsBefore()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = Accounts("add", "--name", "work", "--org", "acme", "--target", Path.Combine(_dir, "target"));
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Account 'work' added.", console.Out, StringComparison.Ordinal);
|
||||
Account account = Store.FindByName("work")!;
|
||||
Assert.Equal(ForgeKind.GitHub, account.Forge);
|
||||
Assert.Null(account.Server);
|
||||
Assert.Equal("github-token", _vault.TryRetrieve(account.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsAdd_OnTheDefaultToken_IsForGitHubOnly()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
CliUsageException error = Assert.Throws<CliUsageException>(
|
||||
() => Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
|
||||
|
||||
Assert.Equal(
|
||||
"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on git.example.test a token of its own.",
|
||||
error.Message);
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsList_SaysWhereEachAccountLives()
|
||||
{
|
||||
AddGiteaAccount();
|
||||
Store.Save(new Account { Id = Guid.NewGuid(), Name = "work", Organization = "acme", TargetRoot = _dir }, "ghp");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
Assert.Equal(0, Accounts());
|
||||
Assert.Equal(0, Accounts("--json"));
|
||||
|
||||
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Contains(lines, line => line.StartsWith("forge", StringComparison.Ordinal) && line.Contains(" git.example.test ", StringComparison.Ordinal));
|
||||
Assert.Contains(lines, line => line.StartsWith("work", StringComparison.Ordinal) && line.Contains(" GitHub ", StringComparison.Ordinal));
|
||||
Assert.Contains("\"forge\":\"gitea\",\"server\":\"https://git.example.test/\"", console.Out, StringComparison.Ordinal);
|
||||
Assert.Contains("\"forge\":\"github\",\"server\":null", console.Out, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("--forge", "github")]
|
||||
[InlineData("--server", "https://elsewhere.example.test")]
|
||||
public void AccountsEdit_CannotMoveAnAccountToAnotherPlace(string option, string value)
|
||||
{
|
||||
Account before = AddGiteaAccount();
|
||||
|
||||
CliUsageException error = Assert.Throws<CliUsageException>(() => Accounts("edit", "--name", "forge", option, value));
|
||||
|
||||
Assert.Contains("cannot be changed: its token was saved for that place", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(before, Store.FindByName("forge"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_AndATokenOptionReplacesIt()
|
||||
{
|
||||
Account account = AddGiteaAccount();
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", "replaced");
|
||||
using var console = new ConsoleCapture();
|
||||
try
|
||||
{
|
||||
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
|
||||
Assert.Equal(
|
||||
"Account 'forge' is on git.example.test: the default token is a GitHub token, so it keeps a token of its own.",
|
||||
error.Message);
|
||||
|
||||
Assert.Equal(0, Accounts("edit", "--name", "forge", "--token-env", "GCLO_FORGE_TEST_TOKEN"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", null);
|
||||
}
|
||||
|
||||
Assert.Equal("replaced", _vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- sync --account
|
||||
|
||||
[Fact]
|
||||
public async Task SyncOfAnAccount_GoesWhereTheAccountWasAddedFor_WithItsOwnToken()
|
||||
{
|
||||
AddGiteaAccount();
|
||||
Tokens(gitHub: "github-token", gitea: "another-gitea-token"); // neither is the account's
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Sync("--account", "forge", "--dry-run");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
ForgeEndpoint asked = Assert.Single(_asked);
|
||||
Assert.Equal(Server, asked.Server!.AbsoluteUri);
|
||||
Assert.Equal("gitea-token", _repos.LastToken); // the one that was saved with the account
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("--forge", "github")]
|
||||
[InlineData("--server", "https://elsewhere.example.test")]
|
||||
public async Task SyncOfAnAccount_TakesNoOtherPlaceFromTheCommandLine(string option, string value)
|
||||
{
|
||||
AddGiteaAccount();
|
||||
|
||||
CliUsageException error = await Assert.ThrowsAsync<CliUsageException>(() => Sync("--account", "forge", option, value, "--dry-run"));
|
||||
|
||||
Assert.Equal("--forge and --server cannot be used with --account: the account says where it lives.", error.Message);
|
||||
Assert.Empty(_asked);
|
||||
Assert.Null(_repos.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAccountThatCannotBeUsedAsItIsSaved_IsNotSynced_AndIsListedAsSuch()
|
||||
{
|
||||
// An accounts.json edited by hand: the server over plain http.
|
||||
Account account = AddGiteaAccount();
|
||||
string file = Path.Combine(_dir, "accounts.json");
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace(Server, "http://git.example.test/", StringComparison.Ordinal));
|
||||
_store = null; // read the file again
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Sync("--account", "forge", "--dry-run"));
|
||||
Assert.Equal(0, Accounts());
|
||||
|
||||
Assert.Equal(
|
||||
"Account 'forge' cannot be used as it is saved: the server address cannot be used: "
|
||||
+ "it has to begin with https://. Remove the account and add it again.",
|
||||
error.Message);
|
||||
Assert.Empty(_asked);
|
||||
Assert.Null(_repos.LastToken);
|
||||
Assert.Contains(" gitea (not usable) ", console.Out, StringComparison.Ordinal);
|
||||
Assert.Equal("gitea-token", _vault.TryRetrieve(account.TokenEntryId)); // untouched, and sent nowhere
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ namespace gclo.Cli;
|
||||
/// <summary>
|
||||
/// One saved account in the JSON listing of 'gclo accounts --json'.
|
||||
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
|
||||
/// when the account has never synced; <see cref="Id"/> is the key of its
|
||||
/// Credential Manager entry ('gclo:account:<id>').
|
||||
/// when the account has never synced; <see cref="Id"/> is the account's id (for a
|
||||
/// GitHub account also the key of its Credential Manager entry,
|
||||
/// 'gclo:account:<id>'). <see cref="Forge"/> is 'github' or 'gitea';
|
||||
/// <see cref="Server"/> is the address of a Gitea account's server, null for GitHub.
|
||||
/// </summary>
|
||||
internal sealed record AccountSummary(
|
||||
string Id,
|
||||
@@ -22,4 +24,6 @@ internal sealed record AccountSummary(
|
||||
int MaxConcurrency,
|
||||
DateTimeOffset? LastSync,
|
||||
string? LastSyncSummary,
|
||||
string TokenSource);
|
||||
string TokenSource,
|
||||
string Forge,
|
||||
string? Server);
|
||||
+65
-17
@@ -30,25 +30,33 @@ internal static class AccountsCommand
|
||||
gclo accounts edit --name <name> [options] [token option]
|
||||
gclo accounts remove --name <name>
|
||||
|
||||
An account pairs sync settings (organization, target root, parallelism,
|
||||
org-subfolder preference, description) with a token stored in Windows
|
||||
Credential Manager. Use one with 'gclo sync --account <name>'. Because the
|
||||
token lives in Windows Credential Manager, accounts only work on Windows.
|
||||
An account pairs sync settings (organization, where it lives, target root,
|
||||
parallelism, org-subfolder preference, description) with a token stored in
|
||||
Windows Credential Manager. Use one with 'gclo sync --account <name>'.
|
||||
Because the token lives in Windows Credential Manager, accounts only work
|
||||
on Windows.
|
||||
|
||||
list (default)
|
||||
Prints the accounts, one per line, in aligned columns: name,
|
||||
organization, target root, token source ('own' or 'default'), and last
|
||||
sync time (local time, 'never' when the account has not completed a
|
||||
sync yet).
|
||||
organization, where it lives ('GitHub', or the host of a Gitea server),
|
||||
target root, token source ('own' or 'default'), and last sync time
|
||||
(local time, 'never' when the account has not completed a sync yet).
|
||||
--json Print a single-line JSON array instead:
|
||||
[{"id":"...","name":"...","description":"...","organization":"...",
|
||||
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
|
||||
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null,
|
||||
"tokenSource":"own"|"default"}]
|
||||
"tokenSource":"own"|"default","forge":"github"|"gitea",
|
||||
"server":"https://git.example.com/"|null}]
|
||||
|
||||
add
|
||||
--name <name> Display name; must be unique (case-insensitive).
|
||||
--org <name> GitHub organization or user account to sync.
|
||||
--org <name> Organization or user account to sync.
|
||||
--forge <kind> Where the organization lives: github (the default)
|
||||
or gitea (a Gitea or Forgejo server; needs --server).
|
||||
--server <address> The address of the Gitea server, for example
|
||||
https://git.example.com. HTTPS only: it is where
|
||||
the token is sent. Implies --forge gitea. Both are
|
||||
fixed once the account is added.
|
||||
--target <folder> Target root folder. A relative folder is stored as
|
||||
a full path, resolved against the current directory.
|
||||
--parallel <N> Parallel clone/pull count, 1-64 (default 8).
|
||||
@@ -56,11 +64,14 @@ internal static class AccountsCommand
|
||||
--description <text> Optional note.
|
||||
--token-env <VAR> | --token-file <path> | --token-stdin
|
||||
The token to store (default: the GITHUB_TOKEN
|
||||
environment variable). Never pass it as an argument.
|
||||
environment variable, or GITEA_TOKEN for a Gitea
|
||||
server). Never pass it as an argument.
|
||||
--use-default-token Instead of a token of its own, the account uses
|
||||
the default token saved in the desktop app's
|
||||
Settings, read whenever it syncs, so replacing
|
||||
the default token there updates this account too.
|
||||
GitHub accounts only: the default token is a
|
||||
GitHub token.
|
||||
|
||||
edit
|
||||
--name <name> The account to change (required).
|
||||
@@ -75,6 +86,9 @@ internal static class AccountsCommand
|
||||
without a token option the token is left untouched.
|
||||
--use-default-token Switch the account to the default token; its own
|
||||
token is removed from Windows Credential Manager.
|
||||
GitHub accounts only.
|
||||
The forge and the server of an account cannot be changed: its token was
|
||||
saved for that place. Remove the account and add it again.
|
||||
|
||||
remove
|
||||
--name <name> Deletes the account's settings and its stored
|
||||
@@ -155,7 +169,8 @@ internal static class AccountsCommand
|
||||
IReadOnlyList<AccountSummary> summaries = accounts
|
||||
.Select(a => new AccountSummary(
|
||||
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a),
|
||||
ForgeLabel(a.Forge), a.Server))
|
||||
.ToList();
|
||||
Terminal.OutVerbatim(JsonSerializer.Serialize(
|
||||
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
|
||||
@@ -171,11 +186,12 @@ internal static class AccountsCommand
|
||||
|
||||
int nameWidth = accounts.Max(a => a.Name.Length);
|
||||
int orgWidth = accounts.Max(a => a.Organization.Length);
|
||||
int whereWidth = accounts.Max(a => Where(a).Length);
|
||||
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
|
||||
foreach(Account account in accounts)
|
||||
{
|
||||
Terminal.Out(
|
||||
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
|
||||
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} {Where(account).PadRight(whereWidth)} "
|
||||
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
@@ -207,11 +223,12 @@ internal static class AccountsCommand
|
||||
int parallel = AppSettings.DefaultConcurrency;
|
||||
bool orgSubfolder = false;
|
||||
var tokenOptions = new TokenOptions();
|
||||
var forgeOptions = new ForgeOptions();
|
||||
|
||||
var reader = new OptionReader(args);
|
||||
while(reader.MoveNext())
|
||||
{
|
||||
if(tokenOptions.TryConsume(reader))
|
||||
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -258,13 +275,19 @@ internal static class AccountsCommand
|
||||
}
|
||||
|
||||
string targetRoot = AbsoluteTarget(target);
|
||||
ForgeEndpoint endpoint = forgeOptions.Resolve();
|
||||
if(tokenOptions.UseDefault && endpoint.Kind != ForgeKind.GitHub)
|
||||
{
|
||||
throw new CliUsageException(
|
||||
$"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on {endpoint} a token of its own.");
|
||||
}
|
||||
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', forge={endpoint}, token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
try
|
||||
{
|
||||
// The token (if the account gets one of its own) is read before the store
|
||||
// opens, so a bad token option fails without touching accounts.json.
|
||||
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve();
|
||||
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve(endpoint.Kind);
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
var account = new Account
|
||||
@@ -277,9 +300,14 @@ internal static class AccountsCommand
|
||||
CreateOrgSubfolder = orgSubfolder,
|
||||
MaxConcurrency = parallel,
|
||||
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
|
||||
Forge = endpoint.Kind,
|
||||
Server = endpoint.Server?.AbsoluteUri,
|
||||
};
|
||||
SaveOrFail(store, account, token);
|
||||
Terminal.Out($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
|
||||
Terminal.Out(
|
||||
$"Account '{account.Name}' added"
|
||||
+ (endpoint.Kind == ForgeKind.GitHub ? "" : $" on {endpoint}")
|
||||
+ (tokenOptions.UseDefault ? " (uses the default token)." : "."));
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
@@ -343,6 +371,10 @@ internal static class AccountsCommand
|
||||
reader.RejectValue();
|
||||
orgSubfolder = true;
|
||||
break;
|
||||
case "--forge" or "--server":
|
||||
throw new CliUsageException(
|
||||
"The forge and the server of an account cannot be changed: its token was saved for that place. "
|
||||
+ "Remove the account and add it again.");
|
||||
case "--no-org-subfolder":
|
||||
reader.RejectValue();
|
||||
orgSubfolder = false;
|
||||
@@ -373,8 +405,13 @@ internal static class AccountsCommand
|
||||
{
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
|
||||
if(tokenOptions.UseDefault && existing.Forge != ForgeKind.GitHub)
|
||||
{
|
||||
throw new CliErrorException(
|
||||
$"Account '{existing.Name}' is on {Where(existing)}: the default token is a GitHub token, so it keeps a token of its own.");
|
||||
}
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null;
|
||||
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve(existing.Forge) : null;
|
||||
|
||||
// A token option decides the source: --use-default-token switches to the
|
||||
// default token (and drops the own entry); --token-* gives the account a
|
||||
@@ -470,6 +507,17 @@ internal static class AccountsCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>The forge as the JSON listing names it.</summary>
|
||||
private static string ForgeLabel(ForgeKind forge) => forge == ForgeKind.GitHub ? "github" : "gitea";
|
||||
|
||||
|
||||
|
||||
/// <summary>Where an account lives, as the listing and messages name it: "GitHub", or the host of its server.</summary>
|
||||
private static string Where(Account account)
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName : $"{ForgeLabel(account.Forge)} (not usable)";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// --use-default-token is only useful when a default token exists: without one
|
||||
/// the account could never sync, so the command fails up front and says where
|
||||
|
||||
@@ -31,12 +31,12 @@ internal sealed class CliErrorException : Exception
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an engine failure: the listers translate GitHub refusals into
|
||||
/// <see cref="GitHubAccessException"/>, whose kind picks the exit code; any other
|
||||
/// Wraps an engine failure: the listers of every forge translate refusals into
|
||||
/// <see cref="ForgeAccessException"/>, whose kind picks the exit code; any other
|
||||
/// <see cref="InvalidOperationException"/> stays fatal (2).
|
||||
/// </summary>
|
||||
public static CliErrorException FromEngine(InvalidOperationException ex)
|
||||
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
|
||||
=> new(ex.Message, ex, ex is ForgeAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ internal static class ExitCodes
|
||||
|
||||
/// <summary>
|
||||
/// Not the caller's doing: a rate limit or temporary throttle, a network failure,
|
||||
/// a timeout, or an error on GitHub's side. Retry later.
|
||||
/// a timeout, or an error on the server's side (GitHub, or a Gitea server). Retry later.
|
||||
/// </summary>
|
||||
public const int Transient = 4;
|
||||
|
||||
@@ -41,10 +41,10 @@ internal static class ExitCodes
|
||||
|
||||
|
||||
/// <summary>The exit code for an engine access refusal of the given kind.</summary>
|
||||
public static int ForAccess(GitHubAccessKind kind) => kind switch
|
||||
public static int ForAccess(ForgeAccessKind kind) => kind switch
|
||||
{
|
||||
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
|
||||
GitHubAccessKind.RateLimited or GitHubAccessKind.Unavailable => Transient,
|
||||
ForgeAccessKind.Unauthorized or ForgeAccessKind.Forbidden => Auth,
|
||||
ForgeAccessKind.RateLimited or ForgeAccessKind.Unavailable => Transient,
|
||||
_ => Fatal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Collects and resolves the options that say where an organization lives:
|
||||
/// <c>--forge github|gitea</c> and, for a Gitea server, <c>--server <address></c>.
|
||||
/// Shared by the commands that talk to a forge. Without either, the forge is GitHub;
|
||||
/// a server alone means a Gitea server, the only kind that has one to name.
|
||||
/// </summary>
|
||||
internal sealed class ForgeOptions
|
||||
{
|
||||
private string? _forge;
|
||||
|
||||
private string? _server;
|
||||
|
||||
|
||||
|
||||
/// <summary>True when <c>--forge</c> or <c>--server</c> was given.</summary>
|
||||
public bool WasGiven => _forge is not null || _server is not null;
|
||||
|
||||
|
||||
|
||||
/// <summary>Consumes the reader's current option when it is one of these.</summary>
|
||||
public bool TryConsume(OptionReader reader)
|
||||
{
|
||||
switch(reader.Current)
|
||||
{
|
||||
case "--forge":
|
||||
_forge = reader.RequireValue();
|
||||
return true;
|
||||
case "--server":
|
||||
_server = reader.RequireValue();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The kind of forge the options name: what <c>--forge</c> says, else Gitea when a server was given, else GitHub.</summary>
|
||||
/// <exception cref="CliUsageException"><c>--forge</c> names no kind gclo knows.</exception>
|
||||
public ForgeKind ResolveKind()
|
||||
{
|
||||
if(_forge is null)
|
||||
{
|
||||
return _server is null ? ForgeKind.GitHub : ForgeKind.Gitea;
|
||||
}
|
||||
return _forge.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"GITHUB" => ForgeKind.GitHub,
|
||||
"GITEA" => ForgeKind.Gitea,
|
||||
_ => throw new CliUsageException($"Unknown forge '{_forge}'. Use github or gitea."),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The place the options name.</summary>
|
||||
/// <exception cref="CliUsageException">The options name no usable place (GitHub with a server, Gitea without one, an address that is not HTTPS).</exception>
|
||||
public ForgeEndpoint Resolve()
|
||||
=> ForgeEndpoint.TryCreate(ResolveKind(), _server, out ForgeEndpoint? endpoint, out string? reason)
|
||||
? endpoint
|
||||
: throw new CliUsageException(char.ToUpperInvariant(reason[0]) + reason[1..] + ".");
|
||||
}
|
||||
+27
-11
@@ -18,12 +18,18 @@ internal static class OrgsCommand
|
||||
|
||||
Lists the logins the token can sync, one per line: the token's own account
|
||||
first, then its organizations alphabetically. A token that cannot list
|
||||
organizations (fine-grained PAT, or a classic PAT without read:org) still
|
||||
prints its own account login.
|
||||
organizations (GitHub: a fine-grained PAT, or a classic PAT without read:org;
|
||||
Gitea: a token without read:organization) still prints its own account login.
|
||||
|
||||
Options:
|
||||
--forge <kind> Where the organization lives: github (the default) or
|
||||
gitea (a Gitea or Forgejo server; needs --server).
|
||||
--server <address> The address of the Gitea server, for example
|
||||
https://git.example.com. HTTPS only: it is where the
|
||||
token is sent. Implies --forge gitea.
|
||||
--token-env <VAR> Read the token from environment variable VAR
|
||||
(default: GITHUB_TOKEN when no token option is given).
|
||||
(default when no token option is given: GITHUB_TOKEN,
|
||||
or GITEA_TOKEN for a Gitea server; never the other's).
|
||||
--token-file <path> Read the token from the first non-blank line of a file.
|
||||
--token-stdin Read the token as one line from standard input.
|
||||
--json Print a single-line JSON array instead.
|
||||
@@ -34,7 +40,7 @@ internal static class OrgsCommand
|
||||
1 canceled (Ctrl+C)
|
||||
2 fatal: bad arguments, or missing token
|
||||
3 the token was rejected (401) or refused access (403)
|
||||
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
|
||||
4 rate limit, network failure, timeout, or an error on the server's side; retry later
|
||||
|
||||
Security:
|
||||
There is deliberately no '--token <value>' option: process command lines
|
||||
@@ -44,24 +50,33 @@ internal static class OrgsCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
|
||||
/// <summary>Composition root: wires the real listers of the forge the options name, and the file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
=> RunAsync(
|
||||
args, endpoint => endpoint.CreateOrganizationLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>The command with one lister, whatever forge the options name.</summary>
|
||||
internal static Task<int> RunAsync(
|
||||
string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, _ => lister, log, cancellationToken);
|
||||
|
||||
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
|
||||
string[] args, Func<ForgeEndpoint, IOrganizationLister> listers, IActivityLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
bool json = false;
|
||||
var tokenOptions = new TokenOptions();
|
||||
var forgeOptions = new ForgeOptions();
|
||||
|
||||
var reader = new OptionReader(args);
|
||||
while(reader.MoveNext())
|
||||
{
|
||||
if(tokenOptions.TryConsume(reader))
|
||||
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -79,16 +94,17 @@ internal static class OrgsCommand
|
||||
}
|
||||
}
|
||||
|
||||
log.Info($"orgs started: json={json}");
|
||||
ForgeEndpoint endpoint = forgeOptions.Resolve();
|
||||
log.Info($"orgs started: forge={endpoint}, json={json}");
|
||||
|
||||
try
|
||||
{
|
||||
string token = tokenOptions.Resolve();
|
||||
string token = tokenOptions.Resolve(endpoint.Kind);
|
||||
|
||||
IReadOnlyList<string> logins;
|
||||
try
|
||||
{
|
||||
logins = await lister
|
||||
logins = await listers(endpoint)
|
||||
.ListOrganizationsAsync(token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
+26
-10
@@ -22,7 +22,12 @@ internal static class ReposCommand
|
||||
selection before syncing, or to feed a script.
|
||||
|
||||
Options:
|
||||
--org <name> GitHub organization or user account (required).
|
||||
--org <name> Organization or user account (required).
|
||||
--forge <kind> Where the organization lives: github (the default) or
|
||||
gitea (a Gitea or Forgejo server; needs --server).
|
||||
--server <address> The address of the Gitea server, for example
|
||||
https://git.example.com. HTTPS only: it is where the
|
||||
token is sent. Implies --forge gitea.
|
||||
--include <glob> Only repositories whose name matches the pattern
|
||||
('*' and '?' wildcards, case-insensitive). Repeatable;
|
||||
a repository matches when any include matches.
|
||||
@@ -30,7 +35,8 @@ internal static class ReposCommand
|
||||
applied after --include.
|
||||
--skip-archived Drop archived repositories.
|
||||
--token-env <VAR> Read the token from environment variable VAR
|
||||
(default: GITHUB_TOKEN when no token option is given).
|
||||
(default when no token option is given: GITHUB_TOKEN,
|
||||
or GITEA_TOKEN for a Gitea server; never the other's).
|
||||
--token-file <path> Read the token from the first non-blank line of a file.
|
||||
--token-stdin Read the token as one line from standard input.
|
||||
--json Print a single-line JSON array instead:
|
||||
@@ -42,31 +48,40 @@ internal static class ReposCommand
|
||||
1 canceled (Ctrl+C)
|
||||
2 fatal: bad arguments, missing token, organization not found
|
||||
3 the token was rejected or may not see the organization
|
||||
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
|
||||
4 rate limit, network failure, timeout, or an error on the server's side; retry later
|
||||
""";
|
||||
|
||||
|
||||
|
||||
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
|
||||
/// <summary>Composition root: wires the real listers of the forge the options name, and the file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
=> RunAsync(
|
||||
args, endpoint => endpoint.CreateRepositoryLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>The command with one lister, whatever forge the options name.</summary>
|
||||
internal static Task<int> RunAsync(
|
||||
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, _ => lister, log, cancellationToken);
|
||||
|
||||
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
|
||||
string[] args, Func<ForgeEndpoint, IRepositoryLister> listers, IActivityLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
string? org = null;
|
||||
bool json = false;
|
||||
var filter = new RepoFilterSpec();
|
||||
var tokenOptions = new TokenOptions();
|
||||
var forgeOptions = new ForgeOptions();
|
||||
|
||||
var reader = new OptionReader(args);
|
||||
while(reader.MoveNext())
|
||||
{
|
||||
if(tokenOptions.TryConsume(reader))
|
||||
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -102,16 +117,17 @@ internal static class ReposCommand
|
||||
throw new CliUsageException("--org is required.");
|
||||
}
|
||||
|
||||
log.Info($"repos started: org='{org}', filters={filter}, json={json}");
|
||||
ForgeEndpoint endpoint = forgeOptions.Resolve();
|
||||
log.Info($"repos started: org='{org}', forge={endpoint}, filters={filter}, json={json}");
|
||||
|
||||
try
|
||||
{
|
||||
string token = tokenOptions.Resolve();
|
||||
string token = tokenOptions.Resolve(endpoint.Kind);
|
||||
|
||||
IReadOnlyList<RepoDescriptor> repositories;
|
||||
try
|
||||
{
|
||||
repositories = await lister
|
||||
repositories = await listers(endpoint)
|
||||
.ListOrganizationRepositoriesAsync(org.Trim(), token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
+72
-19
@@ -23,15 +23,23 @@ internal static class SyncCommand
|
||||
fail independently; one failure never stops the rest.
|
||||
|
||||
Options:
|
||||
--org <name> GitHub organization or user account to sync
|
||||
--org <name> Organization or user account to sync
|
||||
(required unless --account provides it).
|
||||
--forge <kind> Where the organization lives: github (the default) or
|
||||
gitea (a Gitea or Forgejo server; needs --server).
|
||||
--server <address> The address of the Gitea server, for example
|
||||
https://git.example.com. HTTPS only: it is where the
|
||||
token is sent. Implies --forge gitea.
|
||||
Not with --account: the account says where it
|
||||
lives.
|
||||
--target <folder> Local root folder; each repository becomes a subfolder
|
||||
(required unless --account provides it; created if
|
||||
missing).
|
||||
--account <name> Use a saved account (see 'gclo accounts'; Windows
|
||||
only): seeds --org, --target, and --parallel from
|
||||
the account and reads the token from Windows
|
||||
Credential Manager. When the account opts into an
|
||||
the account, syncs from the forge and server the
|
||||
account was added for, and reads the token from
|
||||
Windows Credential Manager. When the account opts into an
|
||||
organization subfolder the target becomes
|
||||
<targetRoot>\<org>. Explicit options override the
|
||||
account's values; a token option overrides the
|
||||
@@ -55,7 +63,8 @@ internal static class SyncCommand
|
||||
listed on stderr and in the activity log, and the
|
||||
repository counts as cloned.
|
||||
--token-env <VAR> Read the token from environment variable VAR
|
||||
(default: GITHUB_TOKEN when no token option is given).
|
||||
(default when no token option is given: GITHUB_TOKEN,
|
||||
or GITEA_TOKEN for a Gitea server; never the other's).
|
||||
--token-file <path> Read the token from the first non-blank line of a file.
|
||||
--token-stdin Read the token as one line from standard input.
|
||||
--json Print no progress lines; end with one line of JSON:
|
||||
@@ -93,7 +102,7 @@ internal static class SyncCommand
|
||||
3 the token was rejected (401) or may not see the organization (403):
|
||||
fix the token, do not retry as is
|
||||
4 not your doing (rate limit, network failure, timeout, or an error on
|
||||
GitHub's side): retry later
|
||||
the server's side): retry later
|
||||
70 an unexpected error inside gclo; see the activity log
|
||||
With --json or --json-lines, exits 2-70 print no document on stdout.
|
||||
|
||||
@@ -129,6 +138,8 @@ internal static class SyncCommand
|
||||
public RepoFilterSpec Filter { get; } = new();
|
||||
|
||||
public TokenOptions TokenOptions { get; } = new();
|
||||
|
||||
public ForgeOptions Forge { get; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +154,7 @@ internal static class SyncCommand
|
||||
var reader = new OptionReader(args);
|
||||
while(reader.MoveNext())
|
||||
{
|
||||
if(options.TokenOptions.TryConsume(reader))
|
||||
if(options.TokenOptions.TryConsume(reader) || options.Forge.TryConsume(reader))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -250,20 +261,33 @@ internal static class SyncCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>Composition root: wires the real GitHub lister, LibGit2 client, and file log.</summary>
|
||||
/// <summary>Composition root: wires the real listers of the forge the run names, the LibGit2 client, and the file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network/native collaborators; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(
|
||||
args,
|
||||
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory),
|
||||
endpoint => endpoint.CreateRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open,
|
||||
new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory),
|
||||
cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>The command with one lister, whatever forge the run names.</summary>
|
||||
internal static Task<int> RunAsync(
|
||||
string[] args,
|
||||
IRepositoryLister lister,
|
||||
IGitClient git,
|
||||
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts,
|
||||
IActivityLog log,
|
||||
CancellationToken cancellationToken)
|
||||
=> RunAsync(args, _ => lister, git, openAccounts, log, cancellationToken);
|
||||
|
||||
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
string[] args,
|
||||
IRepositoryLister lister,
|
||||
Func<ForgeEndpoint, IRepositoryLister> listers,
|
||||
IGitClient git,
|
||||
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts,
|
||||
IActivityLog log,
|
||||
@@ -291,16 +315,14 @@ internal static class SyncCommand
|
||||
{
|
||||
// --account: seed org/target/parallel from the saved account. Explicit
|
||||
// options given alongside win because ??= only fills what is still unset.
|
||||
Account? account = null;
|
||||
AccountsStore? store = null;
|
||||
ITokenVault? vault = null;
|
||||
if(accountName is not null)
|
||||
(Account? account, AccountsStore? store, ITokenVault? vault, ForgeEndpoint endpoint) = WhereFrom(options, openAccounts, log);
|
||||
if(account is not null)
|
||||
{
|
||||
(account, store, vault) = OpenAccount(openAccounts, accountName, log);
|
||||
org ??= account.Organization;
|
||||
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
|
||||
parallel ??= account.MaxConcurrency;
|
||||
}
|
||||
IRepositoryLister lister = listers(endpoint);
|
||||
|
||||
if(string.IsNullOrWhiteSpace(org))
|
||||
{
|
||||
@@ -314,11 +336,11 @@ internal static class SyncCommand
|
||||
string organization = org.Trim();
|
||||
string targetRoot = target.Trim();
|
||||
|
||||
log.Info($"sync started: org='{organization}', target='{targetRoot}', parallel={effectiveParallel}, "
|
||||
log.Info($"sync started: org='{organization}', forge={endpoint}, target='{targetRoot}', parallel={effectiveParallel}, "
|
||||
+ $"filters={filter}, dryRun={dryRun}, sanitizePaths={sanitizePaths}, json={json}, jsonLines={jsonLines}, quiet={quiet}"
|
||||
+ (account is null ? "" : $", account='{account.Name}'"));
|
||||
|
||||
string token = ResolveToken(tokenOptions, account, vault);
|
||||
string token = ResolveToken(tokenOptions, account, vault, endpoint.Kind);
|
||||
|
||||
// List first (the desktop app's list-select-sync shape): filters and
|
||||
// --dry-run need the descriptors before anything runs, and the engine's
|
||||
@@ -480,6 +502,37 @@ internal static class SyncCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The account a run is for, when it is for one, and the place it syncs from: the
|
||||
/// forge and server the account was added for, or what <c>--forge</c> and
|
||||
/// <c>--server</c> name.
|
||||
/// </summary>
|
||||
private static (Account? Account, AccountsStore? Store, ITokenVault? Vault, ForgeEndpoint Endpoint) WhereFrom(
|
||||
SyncOptions options, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts, IActivityLog log)
|
||||
{
|
||||
if(options.AccountName is null)
|
||||
{
|
||||
return (null, null, null, options.Forge.Resolve());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// An account's token belongs to the forge and server the account was //
|
||||
// added for. Nothing on the command line sends it elsewhere. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
if(options.Forge.WasGiven)
|
||||
{
|
||||
throw new CliUsageException("--forge and --server cannot be used with --account: the account says where it lives.");
|
||||
}
|
||||
|
||||
(Account account, AccountsStore store, ITokenVault vault) = OpenAccount(openAccounts, options.AccountName, log);
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? unusable)
|
||||
? (account, store, vault, endpoint)
|
||||
: throw new CliErrorException(
|
||||
$"Account '{account.Name}' cannot be used as it is saved: {unusable}. Remove the account and add it again.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the accounts and finds the one named. A failure of the accounts file or
|
||||
/// of the credential store is a fatal error with its reason (or "retry later"
|
||||
@@ -505,13 +558,13 @@ internal static class SyncCommand
|
||||
/// <summary>
|
||||
/// The token for this run. Any explicit token option wins; otherwise a saved
|
||||
/// account's token comes from Windows Credential Manager; plain runs fall back
|
||||
/// to the default environment variable.
|
||||
/// to the default environment variable of the forge.
|
||||
/// </summary>
|
||||
private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault)
|
||||
private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault, ForgeKind forge)
|
||||
{
|
||||
if(account is null || tokenOptions.HasExplicitSource)
|
||||
{
|
||||
return tokenOptions.Resolve();
|
||||
return tokenOptions.Resolve(forge);
|
||||
}
|
||||
|
||||
// vault is non-null whenever account is: both come from AccountsCommand.Open.
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Collects and resolves the GitHub token source options shared by all commands.
|
||||
/// Collects and resolves the token source options shared by all commands.
|
||||
/// There is deliberately no plain '--token <value>' option: process command
|
||||
/// lines are visible to other processes on the machine (Task Manager, 'ps', WMI
|
||||
/// queries), so a token passed as an argument would leak to every local user and
|
||||
@@ -16,9 +19,12 @@ namespace gclo.Cli;
|
||||
/// </summary>
|
||||
internal sealed class TokenOptions
|
||||
{
|
||||
/// <summary>Environment variable consulted when no token option is given.</summary>
|
||||
/// <summary>Environment variable consulted for GitHub when no token option is given.</summary>
|
||||
public const string DefaultVariable = "GITHUB_TOKEN";
|
||||
|
||||
/// <summary>Environment variable consulted for a Gitea server when no token option is given.</summary>
|
||||
public const string GiteaVariable = "GITEA_TOKEN";
|
||||
|
||||
private string? _envVariable;
|
||||
|
||||
private string? _filePath;
|
||||
@@ -86,11 +92,21 @@ internal sealed class TokenOptions
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token from the single configured source, or from the
|
||||
/// <see cref="DefaultVariable"/> environment variable when none was given.
|
||||
/// The environment variable that holds the token for <paramref name="forge"/> when
|
||||
/// no token option is given. Each forge has its own: a token that is in the
|
||||
/// environment for GitHub is never sent to a Gitea server because no option was
|
||||
/// given, nor the other way round.
|
||||
/// </summary>
|
||||
public static string DefaultVariableFor(ForgeKind forge) => forge == ForgeKind.GitHub ? DefaultVariable : GiteaVariable;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token from the single configured source, or from the default
|
||||
/// environment variable of <paramref name="forge"/> when none was given.
|
||||
/// Throws with an actionable message when the token is missing or empty.
|
||||
/// </summary>
|
||||
public string Resolve()
|
||||
public string Resolve(ForgeKind forge = ForgeKind.GitHub)
|
||||
{
|
||||
EnsureAtMostOneSource();
|
||||
if(_useDefault)
|
||||
@@ -107,7 +123,7 @@ internal sealed class TokenOptions
|
||||
{
|
||||
return FromFile(_filePath);
|
||||
}
|
||||
return FromEnvironment(_envVariable ?? DefaultVariable, wasExplicit: _envVariable is not null);
|
||||
return FromEnvironment(_envVariable ?? DefaultVariableFor(forge), wasExplicit: _envVariable is not null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An account's forge and server: how they are stored, where the account's token is
|
||||
/// kept, and the rules that keep a token on the server it was saved for. Every store
|
||||
/// uses an <see cref="InMemoryVault"/> and a unique temp directory.
|
||||
/// </summary>
|
||||
public sealed class AccountForgeTests : IDisposable
|
||||
{
|
||||
private const string Server = "https://git.example.test/";
|
||||
|
||||
private readonly string _root =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
|
||||
|
||||
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
|
||||
|
||||
|
||||
|
||||
private AccountsStore NewStore() => new(_vault, _root);
|
||||
|
||||
|
||||
|
||||
private string FileText => File.ReadAllText(Path.Combine(_root, "accounts.json"));
|
||||
|
||||
|
||||
|
||||
private static Account GitHubAccount(string name = "Work") => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos",
|
||||
};
|
||||
|
||||
|
||||
|
||||
private static Account GiteaAccount(string name = "Forge", string? server = Server)
|
||||
=> GitHubAccount(name) with { Forge = ForgeKind.Gitea, Server = server };
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- how it is stored
|
||||
|
||||
[Fact]
|
||||
public void AGitHubAccount_IsWrittenWithoutForgeOrServer_SoOldFilesAndOldVersionsSeeNoDifference()
|
||||
{
|
||||
NewStore().Save(GitHubAccount(), "ghp_token");
|
||||
|
||||
Assert.DoesNotContain("Forge", FileText, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Server", FileText, StringComparison.Ordinal);
|
||||
Account reloaded = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(ForgeKind.GitHub, reloaded.Forge);
|
||||
Assert.Null(reloaded.Server);
|
||||
Assert.True(reloaded.TryGetEndpoint(out ForgeEndpoint? endpoint, out _));
|
||||
Assert.Same(ForgeEndpoint.GitHub, endpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("git.example.test")]
|
||||
[InlineData("https://GIT.example.test")]
|
||||
[InlineData(Server)]
|
||||
public void AGiteaAccount_IsWrittenWithItsForge_AndItsServerAsItWasNormalized(string typed)
|
||||
{
|
||||
NewStore().Save(GiteaAccount(server: typed), "gitea_token");
|
||||
|
||||
Assert.Contains("\"Forge\": \"Gitea\"", FileText, StringComparison.Ordinal);
|
||||
Assert.Contains($"\"Server\": \"{Server}\"", FileText, StringComparison.Ordinal);
|
||||
Account reloaded = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(ForgeKind.Gitea, reloaded.Forge);
|
||||
Assert.Equal(Server, reloaded.Server);
|
||||
Assert.True(reloaded.TryGetEndpoint(out ForgeEndpoint? endpoint, out _));
|
||||
Assert.Equal("git.example.test", endpoint.DisplayName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- where its token is kept
|
||||
|
||||
[Fact]
|
||||
public void TheTokenOfAGiteaAccount_IsKeptWhereAVersionThatKnowsOnlyGitHubDoesNotLook()
|
||||
{
|
||||
Account account = GiteaAccount();
|
||||
|
||||
NewStore().Save(account, "gitea_token");
|
||||
|
||||
// A version from before forges reads this account as a GitHub account and
|
||||
// looks for its token under the account's id: it must find none.
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.NotEqual(account.Id, account.TokenEntryId);
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheTokenEntry_IsTheAccountsIdForGitHub_AndAFixedOtherIdForAnotherForge()
|
||||
{
|
||||
Account gitHub = GitHubAccount();
|
||||
var zero = new Account { Id = Guid.Empty, Name = "z", Organization = "o", TargetRoot = "t", Forge = ForgeKind.Gitea, Server = Server };
|
||||
|
||||
Assert.Equal(gitHub.Id, gitHub.TokenEntryId);
|
||||
|
||||
// Pinned: tokens that are saved are found again by this value.
|
||||
Assert.Equal(new Guid("6f0c2a51-9d3e-4b77-8a14-c5e2f0b39d68"), zero.TokenEntryId);
|
||||
Assert.Equal(Guid.Empty, (zero with { Forge = ForgeKind.GitHub, Server = null }).TokenEntryId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Deleting_AGiteaAccount_RemovesItsToken()
|
||||
{
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
|
||||
store.Delete(account.Id);
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ReplacingTheTokenOfAGiteaAccount_ReplacesThatEntry()
|
||||
{
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "first");
|
||||
|
||||
store.Save(account with { Description = "edited" }, "second");
|
||||
|
||||
Assert.Equal("second", _vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the default token is GitHub's
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultToken_IsNeverGivenToAnAccountOnAnotherForge()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
|
||||
|
||||
Assert.Null(resolver.TryResolve(gitea));
|
||||
Assert.Contains("the default token, which is a GitHub token", resolver.MissingDescription(gitea), StringComparison.Ordinal);
|
||||
Assert.Equal("ghp_default", resolver.TryResolve(GitHubAccount() with { TokenSource = TokenSource.Default }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AGiteaAccount_ThatHasNoToken_IsToldWhereItsTokenWouldBe()
|
||||
{
|
||||
Account account = GiteaAccount();
|
||||
|
||||
string missing = new AccountTokenResolver(_vault).MissingDescription(account);
|
||||
|
||||
Assert.Contains(account.TokenEntryId.ToString("N"), missing, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(account.Id.ToString("N"), missing, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_AGiteaAccountOnTheDefaultToken_IsRefused()
|
||||
{
|
||||
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, token: null));
|
||||
|
||||
Assert.Contains("is on git.example.test; the default token is a GitHub token", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- what cannot be saved
|
||||
|
||||
[Theory]
|
||||
[InlineData(ForgeKind.Gitea, null, "a Gitea account needs the address of its server")]
|
||||
[InlineData(ForgeKind.Gitea, "http://git.example.test", "it has to begin with https://")]
|
||||
[InlineData(ForgeKind.Gitea, "https://user:secret@git.example.test", "it contains a user name or a password")]
|
||||
[InlineData(ForgeKind.GitHub, "https://git.example.test", "GitHub has no server address to choose")]
|
||||
public void Save_AnAccountWhoseForgeAndServerCannotBeUsed_IsRefused_AndNothingIsStored(ForgeKind forge, string? server, string said)
|
||||
{
|
||||
Account account = GitHubAccount() with { Forge = forge, Server = server };
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, "a-token"));
|
||||
|
||||
Assert.Contains("cannot be saved", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(said, ex.Message, StringComparison.Ordinal);
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- a token stays with its server
|
||||
|
||||
[Fact]
|
||||
public void TheServerOfAnAccount_CannotBeChanged_SoItsTokenIsNotSentSomewhereElse()
|
||||
{
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
string before = FileText;
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(
|
||||
() => store.Save(account with { Server = "https://elsewhere.example.test" }, token: null));
|
||||
|
||||
Assert.Contains("is on git.example.test, and that cannot be changed: its token was saved for that place", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Delete the account and add it again", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(before, FileText);
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(account.TokenEntryId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheForgeOfAnAccount_CannotBeChanged_EvenWithANewToken()
|
||||
{
|
||||
Account account = GitHubAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "ghp_token");
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(
|
||||
() => store.Save(account with { Forge = ForgeKind.Gitea, Server = Server }, "gitea_token"));
|
||||
|
||||
Assert.Contains("is on GitHub, and that cannot be changed", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Equal("ghp_token", _vault.TryRetrieve(account.Id));
|
||||
Assert.Null(_vault.TryRetrieve((account with { Forge = ForgeKind.Gitea }).TokenEntryId));
|
||||
Assert.Equal(ForgeKind.GitHub, Assert.Single(NewStore().GetAll()).Forge);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheSameServer_SpelledAnotherWay_IsNoChange()
|
||||
{
|
||||
Account account = GiteaAccount(server: "git.example.test");
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
|
||||
store.Save(account with { Server = "HTTPS://git.example.test/", Description = "edited" }, token: null);
|
||||
|
||||
Account saved = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal("edited", saved.Description);
|
||||
Assert.Equal(Server, saved.Server);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnAccountWhoseStoredServerCannotBeUsed_IsNamedAsItIsStored_WhenAChangeIsRefused()
|
||||
{
|
||||
// A file edited by hand: the account is on a server over plain http.
|
||||
Account account = GitHubAccount("Edited");
|
||||
NewStore().Save(account, "ghp_token");
|
||||
File.WriteAllText(
|
||||
Path.Combine(_root, "accounts.json"),
|
||||
FileText.Replace("\"Name\": \"Edited\"", "\"Forge\": \"Gitea\", \"Server\": \"http://old.example.test\", \"Name\": \"Edited\"", StringComparison.Ordinal));
|
||||
AccountsStore store = NewStore();
|
||||
Account onDisk = Assert.Single(store.GetAll());
|
||||
Assert.False(onDisk.TryGetEndpoint(out _, out string? reason));
|
||||
Assert.Contains("https://", reason, StringComparison.Ordinal);
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(
|
||||
() => store.Save(onDisk with { Server = Server }, "gitea_token"));
|
||||
|
||||
Assert.Contains("is on Gitea, at an address that cannot be used, and that cannot be changed", ex.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("old.example.test", ex.Message, StringComparison.Ordinal); // not repeated: it could hold a password
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the listers of an account
|
||||
|
||||
[Fact]
|
||||
public void AnAccountsListers_AreThoseOfItsForge()
|
||||
{
|
||||
Assert.IsType<GitHubRepositoryLister>(AccountListers.RepositoryListerFor(GitHubAccount()));
|
||||
Assert.IsType<GitHubOrganizationLister>(AccountListers.OrganizationListerFor(GitHubAccount()));
|
||||
Assert.IsType<GiteaRepositoryLister>(AccountListers.RepositoryListerFor(GiteaAccount()));
|
||||
Assert.IsType<GiteaOrganizationLister>(AccountListers.OrganizationListerFor(GiteaAccount()));
|
||||
Assert.Throws<ArgumentNullException>(() => AccountListers.RepositoryListerFor(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => AccountListers.OrganizationListerFor(null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AnAccountThatCannotBeUsedAsItIsSaved_AsksNoServerAnything_AndSaysWhy()
|
||||
{
|
||||
Account broken = GiteaAccount("Broken", server: "http://git.example.test");
|
||||
|
||||
ForgeAccessException fromRepositories = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => AccountListers.RepositoryListerFor(broken).ListOrganizationRepositoriesAsync("acme", "a-token"));
|
||||
ForgeAccessException fromOrganizations = await Assert.ThrowsAsync<ForgeAccessException>(
|
||||
() => AccountListers.OrganizationListerFor(broken).ListOrganizationsAsync("a-token"));
|
||||
|
||||
Assert.Equal(ForgeAccessKind.NotFound, fromRepositories.Kind);
|
||||
Assert.Equal(
|
||||
"Account 'Broken' cannot be used as it is saved: the server address cannot be used: "
|
||||
+ "it has to begin with https://. Delete the account and add it again.",
|
||||
fromRepositories.Message);
|
||||
Assert.Equal(fromRepositories.Message, fromOrganizations.Message);
|
||||
Assert.DoesNotContain("a-token", fromRepositories.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the wizard
|
||||
|
||||
[Fact]
|
||||
public async Task TheWizard_EditingAGiteaAccount_KeepsItWhereItIs_AndOffersNoDefaultToken()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
var wizard = new AccountWizardViewModel(
|
||||
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => "ghp_default");
|
||||
|
||||
// The default token is a GitHub token: it is not on offer here.
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
|
||||
wizard.Description = "edited in the wizard";
|
||||
while(wizard.Step < 4)
|
||||
{
|
||||
Assert.True(await wizard.TryAdvanceAsync(), $"stopped at step {wizard.Step}");
|
||||
}
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal("edited in the wizard", saved.Description);
|
||||
Assert.Equal(ForgeKind.Gitea, saved.Forge);
|
||||
Assert.Equal(Server, saved.Server);
|
||||
Assert.Equal("gitea_token", _vault.TryRetrieve(saved.TokenEntryId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheWizard_ForAnAccountOnAnotherForgeThatSaysDefaultToken_DoesNotPreselectIt()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Account edited = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
|
||||
|
||||
var wizard = new AccountWizardViewModel(
|
||||
NewStore(), new FakeOrganizationLister(), new AppSettings(), edited, () => null, defaultToken: () => "ghp_default");
|
||||
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A forge and, where it has one, its server: what may be one, and which listers
|
||||
/// belong to it.
|
||||
/// </summary>
|
||||
public sealed class ForgeEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public void GitHub_IsOneEndpoint_WithoutAServer_AndWithGitHubsListers()
|
||||
{
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.GitHub, null, out ForgeEndpoint? endpoint, out string? reason));
|
||||
|
||||
Assert.Same(ForgeEndpoint.GitHub, endpoint);
|
||||
Assert.Null(reason);
|
||||
Assert.Equal(ForgeKind.GitHub, endpoint.Kind);
|
||||
Assert.Null(endpoint.Server);
|
||||
Assert.Equal("GitHub", endpoint.DisplayName);
|
||||
Assert.Equal("GitHub", endpoint.ToString());
|
||||
Assert.IsType<GitHubRepositoryLister>(endpoint.CreateRepositoryLister());
|
||||
Assert.IsType<GitHubOrganizationLister>(endpoint.CreateOrganizationLister());
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.GitHub, " ", out ForgeEndpoint? blank, out _));
|
||||
Assert.Same(ForgeEndpoint.GitHub, blank);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("git.example.test", "https://git.example.test/", "git.example.test")]
|
||||
[InlineData("https://git.example.test:8443/gitea", "https://git.example.test:8443/gitea/", "git.example.test:8443")]
|
||||
public void AGiteaServer_IsAnEndpointAtItsNormalizedAddress_WithGiteasListers(string typed, string address, string shown)
|
||||
{
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, typed, out ForgeEndpoint? endpoint, out string? reason));
|
||||
|
||||
Assert.Null(reason);
|
||||
Assert.Equal(ForgeKind.Gitea, endpoint.Kind);
|
||||
Assert.Equal(address, endpoint.Server!.AbsoluteUri);
|
||||
Assert.Equal(shown, endpoint.DisplayName);
|
||||
Assert.Equal(shown, endpoint.ToString());
|
||||
Assert.IsType<GiteaRepositoryLister>(endpoint.CreateRepositoryLister());
|
||||
Assert.IsType<GiteaOrganizationLister>(endpoint.CreateOrganizationLister());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(ForgeKind.GitHub, "https://github.example.test", "GitHub has no server address to choose: it is always github.com")]
|
||||
[InlineData(ForgeKind.Gitea, null, "a Gitea account needs the address of its server")]
|
||||
[InlineData(ForgeKind.Gitea, " ", "a Gitea account needs the address of its server")]
|
||||
[InlineData(ForgeKind.Gitea, " http://git.example.test ", "the server address cannot be used: it has to begin with https://")]
|
||||
[InlineData(ForgeKind.Gitea, "https://someone:s3cret-token@git.example.test", "the server address cannot be used: it contains a user name or a password; the token is entered separately")]
|
||||
[InlineData((ForgeKind)99, null, "'99' is not a kind of forge gclo knows")]
|
||||
public void WhatIsNotAnEndpoint_IsRefused_WithTheReason(ForgeKind kind, string? server, string expected)
|
||||
{
|
||||
Assert.False(ForgeEndpoint.TryCreate(kind, server, out ForgeEndpoint? endpoint, out string? reason));
|
||||
|
||||
Assert.Null(endpoint);
|
||||
Assert.Equal(expected, reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Endpoints_AreEqual_WhenTheyAreTheSamePlace()
|
||||
{
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "git.example.test", out ForgeEndpoint? typed, out _));
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "https://GIT.example.test/", out ForgeEndpoint? spelledOut, out _));
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "other.example.test", out ForgeEndpoint? other, out _));
|
||||
|
||||
Assert.Equal(typed, spelledOut);
|
||||
Assert.True(typed.Equals((object)spelledOut));
|
||||
Assert.Equal(typed.GetHashCode(), spelledOut.GetHashCode());
|
||||
Assert.NotEqual(typed, other);
|
||||
Assert.NotEqual(typed, ForgeEndpoint.GitHub);
|
||||
Assert.False(typed.Equals(null));
|
||||
object text = "git.example.test";
|
||||
Assert.False(typed.Equals(text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where an organization or account lives: the kind of forge and, for a forge that
|
||||
/// has no one well-known host, the server's address. The one place that says which
|
||||
/// listers belong to it, so the command line and the desktop app cannot pair an
|
||||
/// account with the listers of another forge.
|
||||
/// </summary>
|
||||
public sealed class ForgeEndpoint : IEquatable<ForgeEndpoint>
|
||||
{
|
||||
/// <summary>github.com.</summary>
|
||||
public static ForgeEndpoint GitHub { get; } = new(ForgeKind.GitHub, null);
|
||||
|
||||
|
||||
|
||||
/// <summary>The kind of forge.</summary>
|
||||
public ForgeKind Kind { get; }
|
||||
|
||||
/// <summary>The server's base address, as <see cref="GiteaServerUrl"/> normalizes it; null for GitHub.</summary>
|
||||
public Uri? Server { get; }
|
||||
|
||||
/// <summary>How messages and lists name the forge: "GitHub", or the server's host (with its port when that is not 443).</summary>
|
||||
public string DisplayName => Server?.Authority ?? "GitHub";
|
||||
|
||||
|
||||
|
||||
private ForgeEndpoint(ForgeKind kind, Uri? server)
|
||||
{
|
||||
Kind = kind;
|
||||
Server = server;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint for a kind of forge and what was given as its server address, or
|
||||
/// the reason there is none: GitHub takes no address, a Gitea server needs one
|
||||
/// that <see cref="GiteaServerUrl.TryNormalize"/> accepts.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of forge.</param>
|
||||
/// <param name="server">The server's address as it was typed or stored; null or blank for none.</param>
|
||||
/// <param name="endpoint">The endpoint; null when there is none.</param>
|
||||
/// <param name="reason">Why there is none; null when there is one.</param>
|
||||
public static bool TryCreate(
|
||||
ForgeKind kind, string? server, [NotNullWhen(true)] out ForgeEndpoint? endpoint, [NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
endpoint = null;
|
||||
bool given = !string.IsNullOrWhiteSpace(server);
|
||||
switch(kind)
|
||||
{
|
||||
case ForgeKind.GitHub:
|
||||
if(given)
|
||||
{
|
||||
reason = "GitHub has no server address to choose: it is always github.com";
|
||||
return false;
|
||||
}
|
||||
endpoint = GitHub;
|
||||
reason = null;
|
||||
return true;
|
||||
|
||||
case ForgeKind.Gitea:
|
||||
if(!given)
|
||||
{
|
||||
reason = "a Gitea account needs the address of its server";
|
||||
return false;
|
||||
}
|
||||
if(!GiteaServerUrl.TryNormalize(server, out Uri? address, out string? whyNot))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// The address is not repeated: it may have been refused for the //
|
||||
// password or the token that is in it. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
reason = $"the server address cannot be used: {whyNot}";
|
||||
return false;
|
||||
}
|
||||
endpoint = new ForgeEndpoint(ForgeKind.Gitea, address);
|
||||
reason = null;
|
||||
return true;
|
||||
|
||||
default:
|
||||
reason = $"'{kind}' is not a kind of forge gclo knows";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The lister of this forge's repositories. For a Gitea server it sends tokens to <see cref="Server"/> only.</summary>
|
||||
public IRepositoryLister CreateRepositoryLister()
|
||||
=> Server is null ? new GitHubRepositoryLister() : new GiteaRepositoryLister(Server);
|
||||
|
||||
|
||||
|
||||
/// <summary>The lister of this forge's organizations. For a Gitea server it sends tokens to <see cref="Server"/> only.</summary>
|
||||
public IOrganizationLister CreateOrganizationLister()
|
||||
=> Server is null ? new GitHubOrganizationLister() : new GiteaOrganizationLister(Server);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(ForgeEndpoint? other)
|
||||
=> other is not null && Kind == other.Kind && Equals(Server, other.Server);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => Equals(obj as ForgeEndpoint);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => HashCode.Combine(Kind, Server);
|
||||
|
||||
|
||||
|
||||
/// <summary>The <see cref="DisplayName"/>.</summary>
|
||||
public override string ToString() => DisplayName;
|
||||
}
|
||||
@@ -3,17 +3,19 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A saved connection profile: which GitHub organization to sync, where its clones
|
||||
/// live on disk, and how the sync runs. Metadata only — the access token is held
|
||||
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
|
||||
/// default token when <see cref="TokenSource"/> says so) and is never serialized
|
||||
/// alongside the account.
|
||||
/// A saved connection profile: which organization to sync (on GitHub, or on a
|
||||
/// Gitea server), where its clones live on disk, and how the sync runs. Metadata
|
||||
/// only — the access token is held separately in an <see cref="ITokenVault"/> (keyed
|
||||
/// by <see cref="TokenEntryId"/>, or the default token when <see cref="TokenSource"/>
|
||||
/// says so) and is never serialized alongside the account.
|
||||
/// </summary>
|
||||
public sealed record Account
|
||||
{
|
||||
@@ -34,6 +36,33 @@ public sealed record Account
|
||||
[JsonIgnore]
|
||||
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of forge the account is on. GitHub is the default, and is left out of
|
||||
/// accounts.json, so files written before forges existed stay byte-identical and
|
||||
/// a GitHub account written now reads the same in an older gclo. Set when the
|
||||
/// account is added and not changed afterwards: see <see cref="AccountsStore.Save"/>.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ForgeKind>))]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ForgeKind Forge { get; init; } = ForgeKind.GitHub;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the account's server, for a forge that has no one well-known
|
||||
/// host (Gitea); null for GitHub, and then left out of accounts.json. It is where
|
||||
/// the account's token is sent, so it is set when the account is added and not
|
||||
/// changed afterwards.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Server { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault entry that holds the account's own token: the account's id for a
|
||||
/// GitHub account, another id for any other forge, so that a version of gclo
|
||||
/// that knows only GitHub finds no token for it (see <see cref="AccountTokenEntry"/>).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Guid TokenEntryId => AccountTokenEntry.For(Id, Forge);
|
||||
|
||||
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
@@ -47,7 +76,7 @@ public sealed record Account
|
||||
init => field = value ?? "";
|
||||
} = "";
|
||||
|
||||
/// <summary>GitHub organization (or user) whose repositories are synced.</summary>
|
||||
/// <summary>The organization (or user account) whose repositories are synced.</summary>
|
||||
public required string Organization { get; init; }
|
||||
|
||||
/// <summary>Folder the sync targets; see <see cref="CreateOrgSubfolder"/>.</summary>
|
||||
@@ -80,4 +109,13 @@ public sealed record Account
|
||||
|
||||
/// <summary>One-line outcome of the last sync, or null if it never ran.</summary>
|
||||
public string? LastSyncSummary { get; init; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where the account lives, or the reason its forge and server cannot be used as
|
||||
/// they are stored (a file edited by hand, or a forge this version does not know).
|
||||
/// </summary>
|
||||
public bool TryGetEndpoint([NotNullWhen(true)] out ForgeEndpoint? endpoint, [NotNullWhen(false)] out string? reason)
|
||||
=> ForgeEndpoint.TryCreate(Forge, Server, out endpoint, out reason);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The listers that belong to an account: those of its forge and, for a Gitea
|
||||
/// account, of its server. The desktop app and the command line both ask here, so
|
||||
/// an account's token is never handed to the listers of another forge.
|
||||
/// </summary>
|
||||
public static class AccountListers
|
||||
{
|
||||
/// <summary>The lister of the repositories on <paramref name="account"/>'s forge.</summary>
|
||||
public static IRepositoryLister RepositoryListerFor(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
|
||||
? endpoint.CreateRepositoryLister()
|
||||
: new UnusableAccountLister(account.Name, reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The lister of the organizations on <paramref name="account"/>'s forge.</summary>
|
||||
public static IOrganizationLister OrganizationListerFor(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
|
||||
? endpoint.CreateOrganizationLister()
|
||||
: new UnusableAccountLister(account.Name, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Which vault entry holds an account's own token. For a GitHub account it is the
|
||||
/// account's id, as it always was. For an account on another forge it is a
|
||||
/// different id, derived from the account's.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A version of gclo from before forges existed reads every account as a GitHub
|
||||
/// account and looks its token up under the account's id. If the token of a Gitea
|
||||
/// account were kept there, that version would send it to GitHub. Kept under an id
|
||||
/// it does not look up, the account has no token as far as that version can tell,
|
||||
/// and it says so instead.
|
||||
/// </remarks>
|
||||
internal static class AccountTokenEntry
|
||||
{
|
||||
/// <summary>What an account's id is combined with for a forge that is not GitHub. Never to be changed: saved tokens are found by it.</summary>
|
||||
private static readonly byte[] s_otherForge = new Guid("6f0c2a51-9d3e-4b77-8a14-c5e2f0b39d68").ToByteArray();
|
||||
|
||||
|
||||
|
||||
/// <summary>The id of the vault entry for the token of the account <paramref name="accountId"/> on <paramref name="forge"/>.</summary>
|
||||
public static Guid For(Guid accountId, ForgeKind forge)
|
||||
{
|
||||
if(forge == ForgeKind.GitHub)
|
||||
{
|
||||
return accountId;
|
||||
}
|
||||
|
||||
byte[] id = accountId.ToByteArray();
|
||||
for(int i = 0; i < id.Length; i++)
|
||||
{
|
||||
id[i] ^= s_otherForge[i];
|
||||
}
|
||||
return new Guid(id);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
@@ -35,9 +38,18 @@ public sealed class AccountTokenResolver
|
||||
public string? TryResolve(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
if(account.TokenSource == TokenSource.Default && account.Forge != ForgeKind.GitHub)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The default token is a GitHub token. It is never handed to an account //
|
||||
// on another forge, however such an account came to say it uses it. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return null;
|
||||
}
|
||||
|
||||
string? token = account.TokenSource == TokenSource.Default
|
||||
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
|
||||
: _vault.TryRetrieve(account.Id);
|
||||
: _vault.TryRetrieve(account.TokenEntryId);
|
||||
return string.IsNullOrEmpty(token) ? null : token;
|
||||
}
|
||||
|
||||
@@ -57,8 +69,12 @@ public sealed class AccountTokenResolver
|
||||
public string MissingDescription(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TokenSource == TokenSource.Default
|
||||
? "uses the default token, but no default token is saved (desktop app: Settings)"
|
||||
: $"has no token in {_vault.DescribeEntry(account.Id)}";
|
||||
if(account.TokenSource == TokenSource.Default)
|
||||
{
|
||||
return account.Forge == ForgeKind.GitHub
|
||||
? "uses the default token, but no default token is saved (desktop app: Settings)"
|
||||
: "is set to use the default token, which is a GitHub token and is not used for another forge; give the account a token of its own";
|
||||
}
|
||||
return $"has no token in {_vault.DescribeEntry(account.TokenEntryId)}";
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// Decided once: the choice is offered only when a default token exists at
|
||||
// open time, and the value itself is fetched again when a step needs it.
|
||||
HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke());
|
||||
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
|
||||
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken && existing.Forge == ForgeKind.GitHub;
|
||||
|
||||
Step = 1;
|
||||
NameError = "";
|
||||
@@ -215,7 +215,12 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// is shown: when a default token is saved, and also for an account that uses the
|
||||
/// default token while none is saved, which otherwise could not be given another.
|
||||
/// </summary>
|
||||
public bool OffersTokenChoice => HasDefaultToken || _existing?.UsesDefaultToken == true;
|
||||
/// <remarks>
|
||||
/// Never for an account that is not on GitHub: the default token is a GitHub
|
||||
/// token, and such an account has a token of its own.
|
||||
/// </remarks>
|
||||
public bool OffersTokenChoice
|
||||
=> (_existing?.Forge ?? ForgeKind.GitHub) == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
|
||||
@@ -476,6 +481,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
CreateOrgSubfolder = CreateOrgSubfolder,
|
||||
MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency),
|
||||
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
|
||||
|
||||
// An account stays on the forge and server it was added for: its token was saved for that place.
|
||||
Forge = _existing?.Forge ?? ForgeKind.GitHub,
|
||||
Server = _existing?.Server,
|
||||
LastSyncUtc = _existing?.LastSyncUtc,
|
||||
LastSyncSummary = _existing?.LastSyncSummary,
|
||||
};
|
||||
|
||||
@@ -171,6 +171,7 @@ public sealed class AccountsStore
|
||||
RefreshForAChange();
|
||||
|
||||
account = ReconcileWithTheFile(account, knownBefore);
|
||||
account = WithItsForgeChecked(account);
|
||||
|
||||
bool nameTaken = _accounts.Any(a =>
|
||||
a.Id != account.Id
|
||||
@@ -214,8 +215,8 @@ public sealed class AccountsStore
|
||||
|
||||
// Vault first: a failing Store leaves the vault exactly as it was (the
|
||||
// previous token, if any, is still there), and nothing else has changed.
|
||||
string? previousToken = _vault.TryRetrieve(account.Id);
|
||||
_vault.Store(account.Id, token);
|
||||
string? previousToken = _vault.TryRetrieve(account.TokenEntryId);
|
||||
_vault.Store(account.TokenEntryId, token);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -228,11 +229,11 @@ public sealed class AccountsStore
|
||||
{
|
||||
if(previousToken is null)
|
||||
{
|
||||
_vault.Delete(account.Id);
|
||||
_vault.Delete(account.TokenEntryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_vault.Store(account.Id, previousToken);
|
||||
_vault.Store(account.TokenEntryId, previousToken);
|
||||
}
|
||||
}
|
||||
catch(Exception compensationFailure)
|
||||
@@ -266,10 +267,10 @@ public sealed class AccountsStore
|
||||
/// </summary>
|
||||
private void SaveUsingDefaultToken(List<Account> updated, Account account, string verb)
|
||||
{
|
||||
string? ownToken = _vault.TryRetrieve(account.Id);
|
||||
string? ownToken = _vault.TryRetrieve(account.TokenEntryId);
|
||||
if(ownToken is not null)
|
||||
{
|
||||
_vault.Delete(account.Id);
|
||||
_vault.Delete(account.TokenEntryId);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -283,7 +284,7 @@ public sealed class AccountsStore
|
||||
}
|
||||
try
|
||||
{
|
||||
_vault.Store(account.Id, ownToken);
|
||||
_vault.Store(account.TokenEntryId, ownToken);
|
||||
}
|
||||
catch(Exception compensationFailure)
|
||||
{
|
||||
@@ -339,12 +340,14 @@ public sealed class AccountsStore
|
||||
{
|
||||
return;
|
||||
}
|
||||
string name = _accounts.First(a => a.Id == id).Name;
|
||||
Account removed = _accounts.First(a => a.Id == id);
|
||||
string name = removed.Name;
|
||||
Guid entry = removed.TokenEntryId;
|
||||
|
||||
// Vault first: a failing Delete leaves both stores untouched. Keep the
|
||||
// token in memory only long enough to put it back if the file write fails.
|
||||
string? removedToken = _vault.TryRetrieve(id);
|
||||
_vault.Delete(id);
|
||||
string? removedToken = _vault.TryRetrieve(entry);
|
||||
_vault.Delete(entry);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -359,7 +362,7 @@ public sealed class AccountsStore
|
||||
|
||||
try
|
||||
{
|
||||
_vault.Store(id, removedToken);
|
||||
_vault.Store(entry, removedToken);
|
||||
}
|
||||
catch(Exception compensationFailure)
|
||||
{
|
||||
@@ -494,6 +497,62 @@ public sealed class AccountsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a save writes for <paramref name="account"/> as far as its forge goes, or
|
||||
/// a refusal. The forge and the server of an account say where its token is sent:
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item>They have to be usable (<see cref="Account.TryGetEndpoint"/>), and the
|
||||
/// server is written the way it was normalized.</item>
|
||||
/// <item>They are set when the account is added and cannot be changed by a save.
|
||||
/// A token that was saved for one server is not sent to another because somebody
|
||||
/// edited the address; the account is deleted and added again, with its token
|
||||
/// entered for the new place.</item>
|
||||
/// <item>The default token is a GitHub token: an account on another forge has a
|
||||
/// token of its own.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private Account WithItsForgeChecked(Account account)
|
||||
{
|
||||
if(!account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason))
|
||||
{
|
||||
throw new ArgumentException($"Account '{account.Name}' cannot be saved: {reason}.", nameof(account));
|
||||
}
|
||||
account = account with { Server = endpoint.Server?.AbsoluteUri };
|
||||
|
||||
Account? onDisk = _accounts.FirstOrDefault(a => a.Id == account.Id);
|
||||
if(onDisk is not null
|
||||
&& (onDisk.Forge != account.Forge || !string.Equals(onDisk.Server, account.Server, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Account '{onDisk.Name}' is on {DescribeForge(onDisk)}, and that cannot be changed: its token was saved for that place. "
|
||||
+ "Delete the account and add it again for the other one.",
|
||||
nameof(account));
|
||||
}
|
||||
|
||||
if(account.UsesDefaultToken && account.Forge != ForgeKind.GitHub)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Account '{account.Name}' is on {endpoint.DisplayName}; the default token is a GitHub token, so the account needs a token of its own.",
|
||||
nameof(account));
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// How a message names the place an account is on, also when what is stored cannot
|
||||
/// be used. Such an address is not repeated: it may hold a password or a token.
|
||||
/// </summary>
|
||||
private static string DescribeForge(Account account)
|
||||
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _)
|
||||
? endpoint.DisplayName
|
||||
: $"{account.Forge}, at an address that cannot be used";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The accounts the file holds now, or null when it holds none that can be used:
|
||||
/// the file is missing, or its content is not a list of accounts (it is then moved
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The listers of an account whose forge or server cannot be used as they are
|
||||
/// stored (an accounts.json edited by hand, or written by a version that knows a
|
||||
/// forge this one does not). It asks no server anything: every call fails with the
|
||||
/// reason, so the account's token goes nowhere.
|
||||
/// </summary>
|
||||
internal sealed class UnusableAccountLister : IRepositoryLister, IOrganizationLister
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the lister for the account <paramref name="account"/>, which cannot be used because of <paramref name="reason"/>.</summary>
|
||||
public UnusableAccountLister(string account, string reason)
|
||||
=> _message = $"Account '{account}' cannot be used as it is saved: {reason}. Delete the account and add it again.";
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
=> Task.FromException<IReadOnlyList<RepoDescriptor>>(new ForgeAccessException(ForgeAccessKind.NotFound, _message));
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
|
||||
=> Task.FromException<IReadOnlyList<string>>(new ForgeAccessException(ForgeAccessKind.NotFound, _message));
|
||||
}
|
||||
@@ -126,10 +126,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
AccountsStore? accountsStore = null,
|
||||
IWslCloner? wsl = null)
|
||||
{
|
||||
_lister = lister ?? new GitHubRepositoryLister();
|
||||
// Without listers of its own, a workspace gets those of its account's forge: Quick Sync is GitHub.
|
||||
_lister = lister ?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account));
|
||||
_git = git ?? new LibGit2GitClient();
|
||||
_wsl = wsl ?? new WslCloner();
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
_orgLister = orgLister ?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account));
|
||||
_progressFactory = progressFactory ?? DefaultProgress;
|
||||
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
|
||||
_log = log ?? new FileActivityLog(GcloPaths.LogDirectory);
|
||||
|
||||
@@ -562,10 +562,11 @@ public sealed partial class MainWindow : Window
|
||||
_log.Error($"No account with id {id:N}; cannot open its workspace.");
|
||||
return null;
|
||||
}
|
||||
// The listers are the account's own: its token goes to the forge and server it was added for.
|
||||
return new WorkspaceViewModel(
|
||||
UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()),
|
||||
UiTestFixture.WrapRepositoryLister(AccountListers.RepositoryListerFor(account)),
|
||||
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
|
||||
UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()),
|
||||
UiTestFixture.WrapOrganizationLister(AccountListers.OrganizationListerFor(account)),
|
||||
account: account,
|
||||
tokenVault: _tokenVault,
|
||||
accountsStore: _accountsStore,
|
||||
@@ -778,9 +779,11 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
// The vault is consulted only if step 2 needs the stored token for a
|
||||
// validation call; the wizard never displays or retains it.
|
||||
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
|
||||
// Its token is checked against the place the account is on, never against another forge.
|
||||
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.TokenEntryId);
|
||||
IOrganizationLister organizations = existing is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(existing);
|
||||
var viewModel = new AccountWizardViewModel(
|
||||
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log,
|
||||
_accountsStore, organizations, _settings, existing, storedToken, _log,
|
||||
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
|
||||
Reference in new issue
Block a user